diff options
Diffstat (limited to 'nixos/modules/security')
29 files changed, 5890 insertions, 0 deletions
diff --git a/nixos/modules/security/acme/default.nix b/nixos/modules/security/acme/default.nix new file mode 100644 index 00000000000..d827c448055 --- /dev/null +++ b/nixos/modules/security/acme/default.nix @@ -0,0 +1,921 @@ +{ config, lib, pkgs, options, ... }: +with lib; +let + cfg = config.security.acme; + opt = options.security.acme; + user = if cfg.useRoot then "root" else "acme"; + + # Used to calculate timer accuracy for coalescing + numCerts = length (builtins.attrNames cfg.certs); + _24hSecs = 60 * 60 * 24; + + # Used to make unique paths for each cert/account config set + mkHash = with builtins; val: substring 0 20 (hashString "sha256" val); + mkAccountHash = acmeServer: data: mkHash "${toString acmeServer} ${data.keyType} ${data.email}"; + accountDirRoot = "/var/lib/acme/.lego/accounts/"; + + # There are many services required to make cert renewals work. + # They all follow a common structure: + # - They inherit this commonServiceConfig + # - They all run as the acme user + # - They all use BindPath and StateDirectory where possible + # to set up a sort of build environment in /tmp + # The Group can vary depending on what the user has specified in + # security.acme.certs.<cert>.group on some of the services. + commonServiceConfig = { + Type = "oneshot"; + User = user; + Group = mkDefault "acme"; + UMask = 0022; + StateDirectoryMode = 750; + ProtectSystem = "strict"; + ReadWritePaths = [ + "/var/lib/acme" + ]; + PrivateTmp = true; + + WorkingDirectory = "/tmp"; + + CapabilityBoundingSet = [ "" ]; + DevicePolicy = "closed"; + LockPersonality = true; + MemoryDenyWriteExecute = true; + NoNewPrivileges = true; + PrivateDevices = true; + ProtectClock = true; + ProtectHome = true; + ProtectHostname = true; + ProtectControlGroups = true; + ProtectKernelLogs = true; + ProtectKernelModules = true; + ProtectKernelTunables = true; + ProtectProc = "invisible"; + ProcSubset = "pid"; + RemoveIPC = true; + RestrictAddressFamilies = [ + "AF_INET" + "AF_INET6" + ]; + RestrictNamespaces = true; + RestrictRealtime = true; + RestrictSUIDSGID = true; + SystemCallArchitectures = "native"; + SystemCallFilter = [ + # 1. allow a reasonable set of syscalls + "@system-service" + # 2. and deny unreasonable ones + "~@privileged @resources" + # 3. then allow the required subset within denied groups + "@chown" + ]; + }; + + # In order to avoid race conditions creating the CA for selfsigned certs, + # we have a separate service which will create the necessary files. + selfsignCAService = { + description = "Generate self-signed certificate authority"; + + path = with pkgs; [ minica ]; + + unitConfig = { + ConditionPathExists = "!/var/lib/acme/.minica/key.pem"; + StartLimitIntervalSec = 0; + }; + + serviceConfig = commonServiceConfig // { + StateDirectory = "acme/.minica"; + BindPaths = "/var/lib/acme/.minica:/tmp/ca"; + UMask = 0077; + }; + + # Working directory will be /tmp + script = '' + minica \ + --ca-key ca/key.pem \ + --ca-cert ca/cert.pem \ + --domains selfsigned.local + ''; + }; + + # Ensures that directories which are shared across all certs + # exist and have the correct user and group, since group + # is configurable on a per-cert basis. + userMigrationService = let + script = with builtins; '' + chown -R ${user} .lego/accounts + '' + (concatStringsSep "\n" (mapAttrsToList (cert: data: '' + for fixpath in ${escapeShellArg cert} .lego/${escapeShellArg cert}; do + if [ -d "$fixpath" ]; then + chmod -R u=rwX,g=rX,o= "$fixpath" + chown -R ${user}:${data.group} "$fixpath" + fi + done + '') certConfigs)); + in { + description = "Fix owner and group of all ACME certificates"; + + serviceConfig = commonServiceConfig // { + # We don't want this to run every time a renewal happens + RemainAfterExit = true; + + # These StateDirectory entries negate the need for tmpfiles + StateDirectory = [ "acme" "acme/.lego" "acme/.lego/accounts" ]; + StateDirectoryMode = 755; + WorkingDirectory = "/var/lib/acme"; + + # Run the start script as root + ExecStart = "+" + (pkgs.writeShellScript "acme-fixperms" script); + }; + }; + + certToConfig = cert: data: let + acmeServer = data.server; + useDns = data.dnsProvider != null; + destPath = "/var/lib/acme/${cert}"; + selfsignedDeps = optionals (cfg.preliminarySelfsigned) [ "acme-selfsigned-${cert}.service" ]; + + # Minica and lego have a "feature" which replaces * with _. We need + # to make this substitution to reference the output files from both programs. + # End users never see this since we rename the certs. + keyName = builtins.replaceStrings ["*"] ["_"] data.domain; + + # FIXME when mkChangedOptionModule supports submodules, change to that. + # This is a workaround + extraDomains = data.extraDomainNames ++ ( + optionals + (data.extraDomains != "_mkMergedOptionModule") + (builtins.attrNames data.extraDomains) + ); + + # Create hashes for cert data directories based on configuration + # Flags are separated to avoid collisions + hashData = with builtins; '' + ${concatStringsSep " " data.extraLegoFlags} - + ${concatStringsSep " " data.extraLegoRunFlags} - + ${concatStringsSep " " data.extraLegoRenewFlags} - + ${toString acmeServer} ${toString data.dnsProvider} + ${toString data.ocspMustStaple} ${data.keyType} + ''; + certDir = mkHash hashData; + # TODO remove domainHash usage entirely. Waiting on go-acme/lego#1532 + domainHash = mkHash "${concatStringsSep " " extraDomains} ${data.domain}"; + accountHash = (mkAccountHash acmeServer data); + accountDir = accountDirRoot + accountHash; + + protocolOpts = if useDns then ( + [ "--dns" data.dnsProvider ] + ++ optionals (!data.dnsPropagationCheck) [ "--dns.disable-cp" ] + ++ optionals (data.dnsResolver != null) [ "--dns.resolvers" data.dnsResolver ] + ) else if data.listenHTTP != null then [ "--http" "--http.port" data.listenHTTP ] + else [ "--http" "--http.webroot" data.webroot ]; + + commonOpts = [ + "--accept-tos" # Checking the option is covered by the assertions + "--path" "." + "-d" data.domain + "--email" data.email + "--key-type" data.keyType + ] ++ protocolOpts + ++ optionals (acmeServer != null) [ "--server" acmeServer ] + ++ concatMap (name: [ "-d" name ]) extraDomains + ++ data.extraLegoFlags; + + # Although --must-staple is common to both modes, it is not declared as a + # mode-agnostic argument in lego and thus must come after the mode. + runOpts = escapeShellArgs ( + commonOpts + ++ [ "run" ] + ++ optionals data.ocspMustStaple [ "--must-staple" ] + ++ data.extraLegoRunFlags + ); + renewOpts = escapeShellArgs ( + commonOpts + ++ [ "renew" ] + ++ optionals data.ocspMustStaple [ "--must-staple" ] + ++ data.extraLegoRenewFlags + ); + + # We need to collect all the ACME webroots to grant them write + # access in the systemd service. + webroots = + lib.remove null + (lib.unique + (builtins.map + (certAttrs: certAttrs.webroot) + (lib.attrValues config.security.acme.certs))); + in { + inherit accountHash cert selfsignedDeps; + + group = data.group; + + renewTimer = { + description = "Renew ACME Certificate for ${cert}"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnCalendar = data.renewInterval; + Unit = "acme-${cert}.service"; + Persistent = "yes"; + + # Allow systemd to pick a convenient time within the day + # to run the check. + # This allows the coalescing of multiple timer jobs. + # We divide by the number of certificates so that if you + # have many certificates, the renewals are distributed over + # the course of the day to avoid rate limits. + AccuracySec = "${toString (_24hSecs / numCerts)}s"; + + # Skew randomly within the day, per https://letsencrypt.org/docs/integration-guide/. + RandomizedDelaySec = "24h"; + }; + }; + + selfsignService = { + description = "Generate self-signed certificate for ${cert}"; + after = [ "acme-selfsigned-ca.service" "acme-fixperms.service" ]; + requires = [ "acme-selfsigned-ca.service" "acme-fixperms.service" ]; + + path = with pkgs; [ minica ]; + + unitConfig = { + ConditionPathExists = "!/var/lib/acme/${cert}/key.pem"; + StartLimitIntervalSec = 0; + }; + + serviceConfig = commonServiceConfig // { + Group = data.group; + UMask = 0027; + + StateDirectory = "acme/${cert}"; + + BindPaths = [ + "/var/lib/acme/.minica:/tmp/ca" + "/var/lib/acme/${cert}:/tmp/${keyName}" + ]; + }; + + # Working directory will be /tmp + # minica will output to a folder sharing the name of the first domain + # in the list, which will be ${data.domain} + script = '' + minica \ + --ca-key ca/key.pem \ + --ca-cert ca/cert.pem \ + --domains ${escapeShellArg (builtins.concatStringsSep "," ([ data.domain ] ++ extraDomains))} + + # Create files to match directory layout for real certificates + cd '${keyName}' + cp ../ca/cert.pem chain.pem + cat cert.pem chain.pem > fullchain.pem + cat key.pem fullchain.pem > full.pem + + # Group might change between runs, re-apply it + chown '${user}:${data.group}' * + + # Default permissions make the files unreadable by group + anon + # Need to be readable by group + chmod 640 * + ''; + }; + + renewService = { + description = "Renew ACME certificate for ${cert}"; + after = [ "network.target" "network-online.target" "acme-fixperms.service" "nss-lookup.target" ] ++ selfsignedDeps; + wants = [ "network-online.target" "acme-fixperms.service" ] ++ selfsignedDeps; + + # https://github.com/NixOS/nixpkgs/pull/81371#issuecomment-605526099 + wantedBy = optionals (!config.boot.isContainer) [ "multi-user.target" ]; + + path = with pkgs; [ lego coreutils diffutils openssl ]; + + serviceConfig = commonServiceConfig // { + Group = data.group; + + # Keep in mind that these directories will be deleted if the user runs + # systemctl clean --what=state + # acme/.lego/${cert} is listed for this reason. + StateDirectory = [ + "acme/${cert}" + "acme/.lego/${cert}" + "acme/.lego/${cert}/${certDir}" + "acme/.lego/accounts/${accountHash}" + ]; + + ReadWritePaths = commonServiceConfig.ReadWritePaths ++ webroots; + + # Needs to be space separated, but can't use a multiline string because that'll include newlines + BindPaths = [ + "${accountDir}:/tmp/accounts" + "/var/lib/acme/${cert}:/tmp/out" + "/var/lib/acme/.lego/${cert}/${certDir}:/tmp/certificates" + ]; + + # Only try loading the credentialsFile if the dns challenge is enabled + EnvironmentFile = mkIf useDns data.credentialsFile; + + # Run as root (Prefixed with +) + ExecStartPost = "+" + (pkgs.writeShellScript "acme-postrun" '' + cd /var/lib/acme/${escapeShellArg cert} + if [ -e renewed ]; then + rm renewed + ${data.postRun} + ${optionalString (data.reloadServices != []) + "systemctl --no-block try-reload-or-restart ${escapeShellArgs data.reloadServices}" + } + fi + ''); + } // optionalAttrs (data.listenHTTP != null && toInt (elemAt (splitString ":" data.listenHTTP) 1) < 1024) { + CapabilityBoundingSet = [ "CAP_NET_BIND_SERVICE" ]; + }; + + # Working directory will be /tmp + script = '' + ${optionalString data.enableDebugLogs "set -x"} + set -euo pipefail + + # This reimplements the expiration date check, but without querying + # the acme server first. By doing this offline, we avoid errors + # when the network or DNS are unavailable, which can happen during + # nixos-rebuild switch. + is_expiration_skippable() { + pem=$1 + + # This function relies on set -e to exit early if any of the + # conditions or programs fail. + + [[ -e $pem ]] + + expiration_line="$( + set -euxo pipefail + openssl x509 -noout -enddate <$pem \ + | grep notAfter \ + | sed -e 's/^notAfter=//' + )" + [[ -n "$expiration_line" ]] + + expiration_date="$(date -d "$expiration_line" +%s)" + now="$(date +%s)" + expiration_s=$[expiration_date - now] + expiration_days=$[expiration_s / (3600 * 24)] # rounds down + + [[ $expiration_days -gt ${toString data.validMinDays} ]] + } + + ${optionalString (data.webroot != null) '' + # Ensure the webroot exists. Fixing group is required in case configuration was changed between runs. + # Lego will fail if the webroot does not exist at all. + ( + mkdir -p '${data.webroot}/.well-known/acme-challenge' \ + && chgrp '${data.group}' ${data.webroot}/.well-known/acme-challenge + ) || ( + echo 'Please ensure ${data.webroot}/.well-known/acme-challenge exists and is writable by acme:${data.group}' \ + && exit 1 + ) + ''} + + echo '${domainHash}' > domainhash.txt + + # Check if we can renew. + # We can only renew if the list of domains has not changed. + if cmp -s domainhash.txt certificates/domainhash.txt && [ -e 'certificates/${keyName}.key' -a -e 'certificates/${keyName}.crt' -a -n "$(ls -1 accounts)" ]; then + + # Even if a cert is not expired, it may be revoked by the CA. + # Try to renew, and silently fail if the cert is not expired. + # Avoids #85794 and resolves #129838 + if ! lego ${renewOpts} --days ${toString data.validMinDays}; then + if is_expiration_skippable out/full.pem; then + echo 1>&2 "nixos-acme: Ignoring failed renewal because expiration isn't within the coming ${toString data.validMinDays} days" + else + # High number to avoid Systemd reserved codes. + exit 11 + fi + fi + + # Otherwise do a full run + elif ! lego ${runOpts}; then + # Produce a nice error for those doing their first nixos-rebuild with these certs + echo Failed to fetch certificates. \ + This may mean your DNS records are set up incorrectly. \ + ${optionalString (cfg.preliminarySelfsigned) "Selfsigned certs are in place and dependant services will still start."} + # Exit 10 so that users can potentially amend SuccessExitStatus to ignore this error. + # High number to avoid Systemd reserved codes. + exit 10 + fi + + mv domainhash.txt certificates/ + + # Group might change between runs, re-apply it + chown '${user}:${data.group}' certificates/* + + # Copy all certs to the "real" certs directory + if ! cmp -s 'certificates/${keyName}.crt' out/fullchain.pem; then + touch out/renewed + echo Installing new certificate + cp -vp 'certificates/${keyName}.crt' out/fullchain.pem + cp -vp 'certificates/${keyName}.key' out/key.pem + cp -vp 'certificates/${keyName}.issuer.crt' out/chain.pem + ln -sf fullchain.pem out/cert.pem + cat out/key.pem out/fullchain.pem > out/full.pem + fi + + # By default group will have no access to the cert files. + # This chmod will fix that. + chmod 640 out/* + ''; + }; + }; + + certConfigs = mapAttrs certToConfig cfg.certs; + + # These options can be specified within + # security.acme.defaults or security.acme.certs.<name> + inheritableModule = isDefaults: { config, ... }: let + defaultAndText = name: default: { + # When ! isDefaults then this is the option declaration for the + # security.acme.certs.<name> path, which has the extra inheritDefaults + # option, which if disabled means that we can't inherit it + default = if isDefaults || ! config.inheritDefaults then default else cfg.defaults.${name}; + # The docs however don't need to depend on inheritDefaults, they should + # stay constant. Though notably it wouldn't matter much, because to get + # the option information, a submodule with name `<name>` is evaluated + # without any definitions. + defaultText = if isDefaults then default else literalExpression "config.security.acme.defaults.${name}"; + }; + in { + options = { + validMinDays = mkOption { + type = types.int; + inherit (defaultAndText "validMinDays" 30) default defaultText; + description = "Minimum remaining validity before renewal in days."; + }; + + renewInterval = mkOption { + type = types.str; + inherit (defaultAndText "renewInterval" "daily") default defaultText; + description = '' + Systemd calendar expression when to check for renewal. See + <citerefentry><refentrytitle>systemd.time</refentrytitle> + <manvolnum>7</manvolnum></citerefentry>. + ''; + }; + + enableDebugLogs = mkEnableOption "debug logging for this certificate" // { + inherit (defaultAndText "enableDebugLogs" true) default defaultText; + }; + + webroot = mkOption { + type = types.nullOr types.str; + inherit (defaultAndText "webroot" null) default defaultText; + example = "/var/lib/acme/acme-challenge"; + description = '' + Where the webroot of the HTTP vhost is located. + <filename>.well-known/acme-challenge/</filename> directory + will be created below the webroot if it doesn't exist. + <literal>http://example.org/.well-known/acme-challenge/</literal> must also + be available (notice unencrypted HTTP). + ''; + }; + + server = mkOption { + type = types.nullOr types.str; + inherit (defaultAndText "server" null) default defaultText; + description = '' + ACME Directory Resource URI. Defaults to Let's Encrypt's + production endpoint, + <link xlink:href="https://acme-v02.api.letsencrypt.org/directory"/>, if unset. + ''; + }; + + email = mkOption { + type = types.str; + inherit (defaultAndText "email" null) default defaultText; + description = '' + Email address for account creation and correspondence from the CA. + It is recommended to use the same email for all certs to avoid account + creation limits. + ''; + }; + + group = mkOption { + type = types.str; + inherit (defaultAndText "group" "acme") default defaultText; + description = "Group running the ACME client."; + }; + + reloadServices = mkOption { + type = types.listOf types.str; + inherit (defaultAndText "reloadServices" []) default defaultText; + description = '' + The list of systemd services to call <code>systemctl try-reload-or-restart</code> + on. + ''; + }; + + postRun = mkOption { + type = types.lines; + inherit (defaultAndText "postRun" "") default defaultText; + example = "cp full.pem backup.pem"; + description = '' + Commands to run after new certificates go live. Note that + these commands run as the root user. + + Executed in the same directory with the new certificate. + ''; + }; + + keyType = mkOption { + type = types.str; + inherit (defaultAndText "keyType" "ec256") default defaultText; + description = '' + Key type to use for private keys. + For an up to date list of supported values check the --key-type option + at <link xlink:href="https://go-acme.github.io/lego/usage/cli/#usage"/>. + ''; + }; + + dnsProvider = mkOption { + type = types.nullOr types.str; + inherit (defaultAndText "dnsProvider" null) default defaultText; + example = "route53"; + description = '' + DNS Challenge provider. For a list of supported providers, see the "code" + field of the DNS providers listed at <link xlink:href="https://go-acme.github.io/lego/dns/"/>. + ''; + }; + + dnsResolver = mkOption { + type = types.nullOr types.str; + inherit (defaultAndText "dnsResolver" null) default defaultText; + example = "1.1.1.1:53"; + description = '' + Set the resolver to use for performing recursive DNS queries. Supported: + host:port. The default is to use the system resolvers, or Google's DNS + resolvers if the system's cannot be determined. + ''; + }; + + credentialsFile = mkOption { + type = types.path; + inherit (defaultAndText "credentialsFile" null) default defaultText; + description = '' + Path to an EnvironmentFile for the cert's service containing any required and + optional environment variables for your selected dnsProvider. + To find out what values you need to set, consult the documentation at + <link xlink:href="https://go-acme.github.io/lego/dns/"/> for the corresponding dnsProvider. + ''; + example = "/var/src/secrets/example.org-route53-api-token"; + }; + + dnsPropagationCheck = mkOption { + type = types.bool; + inherit (defaultAndText "dnsPropagationCheck" true) default defaultText; + description = '' + Toggles lego DNS propagation check, which is used alongside DNS-01 + challenge to ensure the DNS entries required are available. + ''; + }; + + ocspMustStaple = mkOption { + type = types.bool; + inherit (defaultAndText "ocspMustStaple" false) default defaultText; + description = '' + Turns on the OCSP Must-Staple TLS extension. + Make sure you know what you're doing! See: + <itemizedlist> + <listitem><para><link xlink:href="https://blog.apnic.net/2019/01/15/is-the-web-ready-for-ocsp-must-staple/" /></para></listitem> + <listitem><para><link xlink:href="https://blog.hboeck.de/archives/886-The-Problem-with-OCSP-Stapling-and-Must-Staple-and-why-Certificate-Revocation-is-still-broken.html" /></para></listitem> + </itemizedlist> + ''; + }; + + extraLegoFlags = mkOption { + type = types.listOf types.str; + inherit (defaultAndText "extraLegoFlags" []) default defaultText; + description = '' + Additional global flags to pass to all lego commands. + ''; + }; + + extraLegoRenewFlags = mkOption { + type = types.listOf types.str; + inherit (defaultAndText "extraLegoRenewFlags" []) default defaultText; + description = '' + Additional flags to pass to lego renew. + ''; + }; + + extraLegoRunFlags = mkOption { + type = types.listOf types.str; + inherit (defaultAndText "extraLegoRunFlags" []) default defaultText; + description = '' + Additional flags to pass to lego run. + ''; + }; + }; + }; + + certOpts = { name, config, ... }: { + options = { + # user option has been removed + user = mkOption { + visible = false; + default = "_mkRemovedOptionModule"; + }; + + # allowKeysForGroup option has been removed + allowKeysForGroup = mkOption { + visible = false; + default = "_mkRemovedOptionModule"; + }; + + # extraDomains was replaced with extraDomainNames + extraDomains = mkOption { + visible = false; + default = "_mkMergedOptionModule"; + }; + + directory = mkOption { + type = types.str; + readOnly = true; + default = "/var/lib/acme/${name}"; + description = "Directory where certificate and other state is stored."; + }; + + domain = mkOption { + type = types.str; + default = name; + description = "Domain to fetch certificate for (defaults to the entry name)."; + }; + + extraDomainNames = mkOption { + type = types.listOf types.str; + default = []; + example = literalExpression '' + [ + "example.org" + "mydomain.org" + ] + ''; + description = '' + A list of extra domain names, which are included in the one certificate to be issued. + ''; + }; + + # This setting must be different for each configured certificate, otherwise + # two or more renewals may fail to bind to the address. Hence, it is not in + # the inheritableOpts. + listenHTTP = mkOption { + type = types.nullOr types.str; + default = null; + example = ":1360"; + description = '' + Interface and port to listen on to solve HTTP challenges + in the form [INTERFACE]:PORT. + If you use a port other than 80, you must proxy port 80 to this port. + ''; + }; + + inheritDefaults = mkOption { + default = true; + example = true; + description = "Whether to inherit values set in `security.acme.defaults` or not."; + type = lib.types.bool; + }; + }; + }; + +in { + + options = { + security.acme = { + preliminarySelfsigned = mkOption { + type = types.bool; + default = true; + description = '' + Whether a preliminary self-signed certificate should be generated before + doing ACME requests. This can be useful when certificates are required in + a webserver, but ACME needs the webserver to make its requests. + + With preliminary self-signed certificate the webserver can be started and + can later reload the correct ACME certificates. + ''; + }; + + acceptTerms = mkOption { + type = types.bool; + default = false; + description = '' + Accept the CA's terms of service. The default provider is Let's Encrypt, + you can find their ToS at <link xlink:href="https://letsencrypt.org/repository/"/>. + ''; + }; + + useRoot = mkOption { + type = types.bool; + default = false; + description = '' + Whether to use the root user when generating certs. This is not recommended + for security + compatiblity reasons. If a service requires root owned certificates + consider following the guide on "Using ACME with services demanding root + owned certificates" in the NixOS manual, and only using this as a fallback + or for testing. + ''; + }; + + defaults = mkOption { + type = types.submodule (inheritableModule true); + description = '' + Default values inheritable by all configured certs. You can + use this to define options shared by all your certs. These defaults + can also be ignored on a per-cert basis using the + `security.acme.certs.''${cert}.inheritDefaults' option. + ''; + }; + + certs = mkOption { + default = { }; + type = with types; attrsOf (submodule [ (inheritableModule false) certOpts ]); + description = '' + Attribute set of certificates to get signed and renewed. Creates + <literal>acme-''${cert}.{service,timer}</literal> systemd units for + each certificate defined here. Other services can add dependencies + to those units if they rely on the certificates being present, + or trigger restarts of the service if certificates get renewed. + ''; + example = literalExpression '' + { + "example.com" = { + webroot = "/var/lib/acme/acme-challenge/"; + email = "foo@example.com"; + extraDomainNames = [ "www.example.com" "foo.example.com" ]; + }; + "bar.example.com" = { + webroot = "/var/lib/acme/acme-challenge/"; + email = "bar@example.com"; + }; + } + ''; + }; + }; + }; + + imports = [ + (mkRemovedOptionModule [ "security" "acme" "production" ] '' + Use security.acme.server to define your staging ACME server URL instead. + + To use the let's encrypt staging server, use security.acme.server = + "https://acme-staging-v02.api.letsencrypt.org/directory". + '') + (mkRemovedOptionModule [ "security" "acme" "directory" ] "ACME Directory is now hardcoded to /var/lib/acme and its permisisons are managed by systemd. See https://github.com/NixOS/nixpkgs/issues/53852 for more info.") + (mkRemovedOptionModule [ "security" "acme" "preDelay" ] "This option has been removed. If you want to make sure that something executes before certificates are provisioned, add a RequiredBy=acme-\${cert}.service to the service you want to execute before the cert renewal") + (mkRemovedOptionModule [ "security" "acme" "activationDelay" ] "This option has been removed. If you want to make sure that something executes before certificates are provisioned, add a RequiredBy=acme-\${cert}.service to the service you want to execute before the cert renewal") + (mkChangedOptionModule [ "security" "acme" "validMin" ] [ "security" "acme" "defaults" "validMinDays" ] (config: config.security.acme.validMin / (24 * 3600))) + (mkChangedOptionModule [ "security" "acme" "validMinDays" ] [ "security" "acme" "defaults" "validMinDays" ] (config: config.security.acme.validMinDays)) + (mkChangedOptionModule [ "security" "acme" "renewInterval" ] [ "security" "acme" "defaults" "renewInterval" ] (config: config.security.acme.renewInterval)) + (mkChangedOptionModule [ "security" "acme" "email" ] [ "security" "acme" "defaults" "email" ] (config: config.security.acme.email)) + (mkChangedOptionModule [ "security" "acme" "server" ] [ "security" "acme" "defaults" "server" ] (config: config.security.acme.server)) + (mkChangedOptionModule [ "security" "acme" "enableDebugLogs" ] [ "security" "acme" "defaults" "enableDebugLogs" ] (config: config.security.acme.enableDebugLogs)) + ]; + + config = mkMerge [ + (mkIf (cfg.certs != { }) { + + # FIXME Most of these custom warnings and filters for security.acme.certs.* are required + # because using mkRemovedOptionModule/mkChangedOptionModule with attrsets isn't possible. + warnings = filter (w: w != "") (mapAttrsToList (cert: data: if data.extraDomains != "_mkMergedOptionModule" then '' + The option definition `security.acme.certs.${cert}.extraDomains` has changed + to `security.acme.certs.${cert}.extraDomainNames` and is now a list of strings. + Setting a custom webroot for extra domains is not possible, instead use separate certs. + '' else "") cfg.certs); + + assertions = let + certs = attrValues cfg.certs; + in [ + { + assertion = cfg.email != null || all (certOpts: certOpts.email != null) certs; + message = '' + You must define `security.acme.certs.<name>.email` or + `security.acme.email` to register with the CA. Note that using + many different addresses for certs may trigger account rate limits. + ''; + } + { + assertion = cfg.acceptTerms; + message = '' + You must accept the CA's terms of service before using + the ACME module by setting `security.acme.acceptTerms` + to `true`. For Let's Encrypt's ToS see https://letsencrypt.org/repository/ + ''; + } + ] ++ (builtins.concatLists (mapAttrsToList (cert: data: [ + { + assertion = data.user == "_mkRemovedOptionModule"; + message = '' + The option definition `security.acme.certs.${cert}.user' no longer has any effect; Please remove it. + Certificate user is now hard coded to the "acme" user. If you would + like another user to have access, consider adding them to the + "acme" group or changing security.acme.certs.${cert}.group. + ''; + } + { + assertion = data.allowKeysForGroup == "_mkRemovedOptionModule"; + message = '' + The option definition `security.acme.certs.${cert}.allowKeysForGroup' no longer has any effect; Please remove it. + All certs are readable by the configured group. If this is undesired, + consider changing security.acme.certs.${cert}.group to an unused group. + ''; + } + # * in the cert value breaks building of systemd services, and makes + # referencing them as a user quite weird too. Best practice is to use + # the domain option. + { + assertion = ! hasInfix "*" cert; + message = '' + The cert option path `security.acme.certs.${cert}.dnsProvider` + cannot contain a * character. + Instead, set `security.acme.certs.${cert}.domain = "${cert}";` + and remove the wildcard from the path. + ''; + } + { + assertion = data.dnsProvider == null || data.webroot == null; + message = '' + Options `security.acme.certs.${cert}.dnsProvider` and + `security.acme.certs.${cert}.webroot` are mutually exclusive. + ''; + } + { + assertion = data.webroot == null || data.listenHTTP == null; + message = '' + Options `security.acme.certs.${cert}.webroot` and + `security.acme.certs.${cert}.listenHTTP` are mutually exclusive. + ''; + } + { + assertion = data.listenHTTP == null || data.dnsProvider == null; + message = '' + Options `security.acme.certs.${cert}.listenHTTP` and + `security.acme.certs.${cert}.dnsProvider` are mutually exclusive. + ''; + } + { + assertion = data.dnsProvider != null || data.webroot != null || data.listenHTTP != null; + message = '' + One of `security.acme.certs.${cert}.dnsProvider`, + `security.acme.certs.${cert}.webroot`, or + `security.acme.certs.${cert}.listenHTTP` must be provided. + ''; + } + ]) cfg.certs)); + + users.users.acme = { + home = "/var/lib/acme"; + group = "acme"; + isSystemUser = true; + }; + + users.groups.acme = {}; + + systemd.services = { + "acme-fixperms" = userMigrationService; + } // (mapAttrs' (cert: conf: nameValuePair "acme-${cert}" conf.renewService) certConfigs) + // (optionalAttrs (cfg.preliminarySelfsigned) ({ + "acme-selfsigned-ca" = selfsignCAService; + } // (mapAttrs' (cert: conf: nameValuePair "acme-selfsigned-${cert}" conf.selfsignService) certConfigs))); + + systemd.timers = mapAttrs' (cert: conf: nameValuePair "acme-${cert}" conf.renewTimer) certConfigs; + + systemd.targets = let + # Create some targets which can be depended on to be "active" after cert renewals + finishedTargets = mapAttrs' (cert: conf: nameValuePair "acme-finished-${cert}" { + wantedBy = [ "default.target" ]; + requires = [ "acme-${cert}.service" ]; + after = [ "acme-${cert}.service" ]; + }) certConfigs; + + # Create targets to limit the number of simultaneous account creations + # How it works: + # - Pick a "leader" cert service, which will be in charge of creating the account, + # and run first (requires + after) + # - Make all other cert services sharing the same account wait for the leader to + # finish before starting (requiredBy + before). + # Using a target here is fine - account creation is a one time event. Even if + # systemd clean --what=state is used to delete the account, so long as the user + # then runs one of the cert services, there won't be any issues. + accountTargets = mapAttrs' (hash: confs: let + leader = "acme-${(builtins.head confs).cert}.service"; + dependantServices = map (conf: "acme-${conf.cert}.service") (builtins.tail confs); + in nameValuePair "acme-account-${hash}" { + requiredBy = dependantServices; + before = dependantServices; + requires = [ leader ]; + after = [ leader ]; + }) (groupBy (conf: conf.accountHash) (attrValues certConfigs)); + in finishedTargets // accountTargets; + }) + ]; + + meta = { + maintainers = lib.teams.acme.members; + doc = ./doc.xml; + }; +} diff --git a/nixos/modules/security/acme/doc.xml b/nixos/modules/security/acme/doc.xml new file mode 100644 index 00000000000..f623cc509be --- /dev/null +++ b/nixos/modules/security/acme/doc.xml @@ -0,0 +1,413 @@ +<chapter xmlns="http://docbook.org/ns/docbook" + xmlns:xlink="http://www.w3.org/1999/xlink" + xmlns:xi="http://www.w3.org/2001/XInclude" + version="5.0" + xml:id="module-security-acme"> + <title>SSL/TLS Certificates with ACME</title> + <para> + NixOS supports automatic domain validation & certificate retrieval and + renewal using the ACME protocol. Any provider can be used, but by default + NixOS uses Let's Encrypt. The alternative ACME client + <link xlink:href="https://go-acme.github.io/lego/">lego</link> is used under + the hood. + </para> + <para> + Automatic cert validation and configuration for Apache and Nginx virtual + hosts is included in NixOS, however if you would like to generate a wildcard + cert or you are not using a web server you will have to configure DNS + based validation. + </para> + <section xml:id="module-security-acme-prerequisites"> + <title>Prerequisites</title> + + <para> + To use the ACME module, you must accept the provider's terms of service + by setting <literal><xref linkend="opt-security.acme.acceptTerms" /></literal> + to <literal>true</literal>. The Let's Encrypt ToS can be found + <link xlink:href="https://letsencrypt.org/repository/">here</link>. + </para> + + <para> + You must also set an email address to be used when creating accounts with + Let's Encrypt. You can set this for all certs with + <literal><xref linkend="opt-security.acme.defaults.email" /></literal> + and/or on a per-cert basis with + <literal><xref linkend="opt-security.acme.certs._name_.email" /></literal>. + This address is only used for registration and renewal reminders, + and cannot be used to administer the certificates in any way. + </para> + + <para> + Alternatively, you can use a different ACME server by changing the + <literal><xref linkend="opt-security.acme.defaults.server" /></literal> option + to a provider of your choosing, or just change the server for one cert with + <literal><xref linkend="opt-security.acme.certs._name_.server" /></literal>. + </para> + + <para> + You will need an HTTP server or DNS server for verification. For HTTP, + the server must have a webroot defined that can serve + <filename>.well-known/acme-challenge</filename>. This directory must be + writeable by the user that will run the ACME client. For DNS, you must + set up credentials with your provider/server for use with lego. + </para> + </section> + <section xml:id="module-security-acme-nginx"> + <title>Using ACME certificates in Nginx</title> + + <para> + NixOS supports fetching ACME certificates for you by setting + <literal><link linkend="opt-services.nginx.virtualHosts._name_.enableACME">enableACME</link> + = true;</literal> in a virtualHost config. We first create self-signed + placeholder certificates in place of the real ACME certs. The placeholder + certs are overwritten when the ACME certs arrive. For + <literal>foo.example.com</literal> the config would look like this: + </para> + +<programlisting> +<xref linkend="opt-security.acme.acceptTerms" /> = true; +<xref linkend="opt-security.acme.defaults.email" /> = "admin+acme@example.com"; +services.nginx = { + <link linkend="opt-services.nginx.enable">enable</link> = true; + <link linkend="opt-services.nginx.virtualHosts">virtualHosts</link> = { + "foo.example.com" = { + <link linkend="opt-services.nginx.virtualHosts._name_.forceSSL">forceSSL</link> = true; + <link linkend="opt-services.nginx.virtualHosts._name_.enableACME">enableACME</link> = true; + # All serverAliases will be added as <link linkend="opt-security.acme.certs._name_.extraDomainNames">extra domain names</link> on the certificate. + <link linkend="opt-services.nginx.virtualHosts._name_.serverAliases">serverAliases</link> = [ "bar.example.com" ]; + locations."/" = { + <link linkend="opt-services.nginx.virtualHosts._name_.locations._name_.root">root</link> = "/var/www"; + }; + }; + + # We can also add a different vhost and reuse the same certificate + # but we have to append extraDomainNames manually. + <link linkend="opt-security.acme.certs._name_.extraDomainNames">security.acme.certs."foo.example.com".extraDomainNames</link> = [ "baz.example.com" ]; + "baz.example.com" = { + <link linkend="opt-services.nginx.virtualHosts._name_.forceSSL">forceSSL</link> = true; + <link linkend="opt-services.nginx.virtualHosts._name_.useACMEHost">useACMEHost</link> = "foo.example.com"; + locations."/" = { + <link linkend="opt-services.nginx.virtualHosts._name_.locations._name_.root">root</link> = "/var/www"; + }; + }; + }; +} +</programlisting> + </section> + <section xml:id="module-security-acme-httpd"> + <title>Using ACME certificates in Apache/httpd</title> + + <para> + Using ACME certificates with Apache virtual hosts is identical + to using them with Nginx. The attribute names are all the same, just replace + "nginx" with "httpd" where appropriate. + </para> + </section> + <section xml:id="module-security-acme-configuring"> + <title>Manual configuration of HTTP-01 validation</title> + + <para> + First off you will need to set up a virtual host to serve the challenges. + This example uses a vhost called <literal>certs.example.com</literal>, with + the intent that you will generate certs for all your vhosts and redirect + everyone to HTTPS. + </para> + +<programlisting> +<xref linkend="opt-security.acme.acceptTerms" /> = true; +<xref linkend="opt-security.acme.defaults.email" /> = "admin+acme@example.com"; + +# /var/lib/acme/.challenges must be writable by the ACME user +# and readable by the Nginx user. The easiest way to achieve +# this is to add the Nginx user to the ACME group. +<link linkend="opt-users.users._name_.extraGroups">users.users.nginx.extraGroups</link> = [ "acme" ]; + +services.nginx = { + <link linkend="opt-services.nginx.enable">enable</link> = true; + <link linkend="opt-services.nginx.virtualHosts">virtualHosts</link> = { + "acmechallenge.example.com" = { + # Catchall vhost, will redirect users to HTTPS for all vhosts + <link linkend="opt-services.nginx.virtualHosts._name_.serverAliases">serverAliases</link> = [ "*.example.com" ]; + locations."/.well-known/acme-challenge" = { + <link linkend="opt-services.nginx.virtualHosts._name_.locations._name_.root">root</link> = "/var/lib/acme/.challenges"; + }; + locations."/" = { + <link linkend="opt-services.nginx.virtualHosts._name_.locations._name_.return">return</link> = "301 https://$host$request_uri"; + }; + }; + }; +} +# Alternative config for Apache +<link linkend="opt-users.users._name_.extraGroups">users.users.wwwrun.extraGroups</link> = [ "acme" ]; +services.httpd = { + <link linkend="opt-services.httpd.enable">enable = true;</link> + <link linkend="opt-services.httpd.virtualHosts">virtualHosts</link> = { + "acmechallenge.example.com" = { + # Catchall vhost, will redirect users to HTTPS for all vhosts + <link linkend="opt-services.httpd.virtualHosts._name_.serverAliases">serverAliases</link> = [ "*.example.com" ]; + # /var/lib/acme/.challenges must be writable by the ACME user and readable by the Apache user. + # By default, this is the case. + <link linkend="opt-services.httpd.virtualHosts._name_.documentRoot">documentRoot</link> = "/var/lib/acme/.challenges"; + <link linkend="opt-services.httpd.virtualHosts._name_.extraConfig">extraConfig</link> = '' + RewriteEngine On + RewriteCond %{HTTPS} off + RewriteCond %{REQUEST_URI} !^/\.well-known/acme-challenge [NC] + RewriteRule (.*) https://%{HTTP_HOST}%{REQUEST_URI} [R=301] + ''; + }; + }; +} +</programlisting> + + <para> + Now you need to configure ACME to generate a certificate. + </para> + +<programlisting> +<xref linkend="opt-security.acme.certs"/>."foo.example.com" = { + <link linkend="opt-security.acme.certs._name_.webroot">webroot</link> = "/var/lib/acme/.challenges"; + <link linkend="opt-security.acme.certs._name_.email">email</link> = "foo@example.com"; + # Ensure that the web server you use can read the generated certs + # Take a look at the <link linkend="opt-services.nginx.group">group</link> option for the web server you choose. + <link linkend="opt-security.acme.certs._name_.group">group</link> = "nginx"; + # Since we have a wildcard vhost to handle port 80, + # we can generate certs for anything! + # Just make sure your DNS resolves them. + <link linkend="opt-security.acme.certs._name_.extraDomainNames">extraDomainNames</link> = [ "mail.example.com" ]; +}; +</programlisting> + + <para> + The private key <filename>key.pem</filename> and certificate + <filename>fullchain.pem</filename> will be put into + <filename>/var/lib/acme/foo.example.com</filename>. + </para> + + <para> + Refer to <xref linkend="ch-options" /> for all available configuration + options for the <link linkend="opt-security.acme.certs">security.acme</link> + module. + </para> + </section> + <section xml:id="module-security-acme-config-dns"> + <title>Configuring ACME for DNS validation</title> + + <para> + This is useful if you want to generate a wildcard certificate, since + ACME servers will only hand out wildcard certs over DNS validation. + There are a number of supported DNS providers and servers you can utilise, + see the <link xlink:href="https://go-acme.github.io/lego/dns/">lego docs</link> + for provider/server specific configuration values. For the sake of these + docs, we will provide a fully self-hosted example using bind. + </para> + +<programlisting> +services.bind = { + <link linkend="opt-services.bind.enable">enable</link> = true; + <link linkend="opt-services.bind.extraConfig">extraConfig</link> = '' + include "/var/lib/secrets/dnskeys.conf"; + ''; + <link linkend="opt-services.bind.zones">zones</link> = [ + rec { + name = "example.com"; + file = "/var/db/bind/${name}"; + master = true; + extraConfig = "allow-update { key rfc2136key.example.com.; };"; + } + ]; +} + +# Now we can configure ACME +<xref linkend="opt-security.acme.acceptTerms" /> = true; +<xref linkend="opt-security.acme.defaults.email" /> = "admin+acme@example.com"; +<xref linkend="opt-security.acme.certs" />."example.com" = { + <link linkend="opt-security.acme.certs._name_.domain">domain</link> = "*.example.com"; + <link linkend="opt-security.acme.certs._name_.dnsProvider">dnsProvider</link> = "rfc2136"; + <link linkend="opt-security.acme.certs._name_.credentialsFile">credentialsFile</link> = "/var/lib/secrets/certs.secret"; + # We don't need to wait for propagation since this is a local DNS server + <link linkend="opt-security.acme.certs._name_.dnsPropagationCheck">dnsPropagationCheck</link> = false; +}; +</programlisting> + + <para> + The <filename>dnskeys.conf</filename> and <filename>certs.secret</filename> + must be kept secure and thus you should not keep their contents in your + Nix config. Instead, generate them one time with a systemd service: + </para> + +<programlisting> +systemd.services.dns-rfc2136-conf = { + requiredBy = ["acme-example.com.service", "bind.service"]; + before = ["acme-example.com.service", "bind.service"]; + unitConfig = { + ConditionPathExists = "!/var/lib/secrets/dnskeys.conf"; + }; + serviceConfig = { + Type = "oneshot"; + UMask = 0077; + }; + path = [ pkgs.bind ]; + script = '' + mkdir -p /var/lib/secrets + tsig-keygen rfc2136key.example.com > /var/lib/secrets/dnskeys.conf + chown named:root /var/lib/secrets/dnskeys.conf + chmod 400 /var/lib/secrets/dnskeys.conf + + # Copy the secret value from the dnskeys.conf, and put it in + # RFC2136_TSIG_SECRET below + + cat > /var/lib/secrets/certs.secret << EOF + RFC2136_NAMESERVER='127.0.0.1:53' + RFC2136_TSIG_ALGORITHM='hmac-sha256.' + RFC2136_TSIG_KEY='rfc2136key.example.com' + RFC2136_TSIG_SECRET='your secret key' + EOF + chmod 400 /var/lib/secrets/certs.secret + ''; +}; +</programlisting> + + <para> + Now you're all set to generate certs! You should monitor the first invocation + by running <literal>systemctl start acme-example.com.service & + journalctl -fu acme-example.com.service</literal> and watching its log output. + </para> + </section> + + <section xml:id="module-security-acme-config-dns-with-vhosts"> + <title>Using DNS validation with web server virtual hosts</title> + + <para> + It is possible to use DNS-01 validation with all certificates, + including those automatically configured via the Nginx/Apache + <literal><link linkend="opt-services.nginx.virtualHosts._name_.enableACME">enableACME</link></literal> + option. This configuration pattern is fully + supported and part of the module's test suite for Nginx + Apache. + </para> + + <para> + You must follow the guide above on configuring DNS-01 validation + first, however instead of setting the options for one certificate + (e.g. <xref linkend="opt-security.acme.certs._name_.dnsProvider" />) + you will set them as defaults + (e.g. <xref linkend="opt-security.acme.defaults.dnsProvider" />). + </para> + +<programlisting> +# Configure ACME appropriately +<xref linkend="opt-security.acme.acceptTerms" /> = true; +<xref linkend="opt-security.acme.defaults.email" /> = "admin+acme@example.com"; +<xref linkend="opt-security.acme.defaults" /> = { + <link linkend="opt-security.acme.defaults.dnsProvider">dnsProvider</link> = "rfc2136"; + <link linkend="opt-security.acme.defaults.credentialsFile">credentialsFile</link> = "/var/lib/secrets/certs.secret"; + # We don't need to wait for propagation since this is a local DNS server + <link linkend="opt-security.acme.defaults.dnsPropagationCheck">dnsPropagationCheck</link> = false; +}; + +# For each virtual host you would like to use DNS-01 validation with, +# set acmeRoot = null +services.nginx = { + <link linkend="opt-services.nginx.enable">enable</link> = true; + <link linkend="opt-services.nginx.virtualHosts">virtualHosts</link> = { + "foo.example.com" = { + <link linkend="opt-services.nginx.virtualHosts._name_.enableACME">enableACME</link> = true; + <link linkend="opt-services.nginx.virtualHosts._name_.acmeRoot">acmeRoot</link> = null; + }; + }; +} +</programlisting> + + <para> + And that's it! Next time your configuration is rebuilt, or when + you add a new virtualHost, it will be DNS-01 validated. + </para> + </section> + + <section xml:id="module-security-acme-root-owned"> + <title>Using ACME with services demanding root owned certificates</title> + + <para> + Some services refuse to start if the configured certificate files + are not owned by root. PostgreSQL and OpenSMTPD are examples of these. + There is no way to change the user the ACME module uses (it will always be + <literal>acme</literal>), however you can use systemd's + <literal>LoadCredential</literal> feature to resolve this elegantly. + Below is an example configuration for OpenSMTPD, but this pattern + can be applied to any service. + </para> + +<programlisting> +# Configure ACME however you like (DNS or HTTP validation), adding +# the following configuration for the relevant certificate. +# Note: You cannot use `systemctl reload` here as that would mean +# the LoadCredential configuration below would be skipped and +# the service would continue to use old certificates. +security.acme.certs."mail.example.com".postRun = '' + systemctl restart opensmtpd +''; + +# Now you must augment OpenSMTPD's systemd service to load +# the certificate files. +<link linkend="opt-systemd.services._name_.requires">systemd.services.opensmtpd.requires</link> = ["acme-finished-mail.example.com.target"]; +<link linkend="opt-systemd.services._name_.serviceConfig">systemd.services.opensmtpd.serviceConfig.LoadCredential</link> = let + certDir = config.security.acme.certs."mail.example.com".directory; +in [ + "cert.pem:${certDir}/cert.pem" + "key.pem:${certDir}/key.pem" +]; + +# Finally, configure OpenSMTPD to use these certs. +services.opensmtpd = let + credsDir = "/run/credentials/opensmtpd.service"; +in { + enable = true; + setSendmail = false; + serverConfiguration = '' + pki mail.example.com cert "${credsDir}/cert.pem" + pki mail.example.com key "${credsDir}/key.pem" + listen on localhost tls pki mail.example.com + action act1 relay host smtp://127.0.0.1:10027 + match for local action act1 + ''; +}; +</programlisting> + </section> + + <section xml:id="module-security-acme-regenerate"> + <title>Regenerating certificates</title> + + <para> + Should you need to regenerate a particular certificate in a hurry, such + as when a vulnerability is found in Let's Encrypt, there is now a convenient + mechanism for doing so. Running + <literal>systemctl clean --what=state acme-example.com.service</literal> + will remove all certificate files and the account data for the given domain, + allowing you to then <literal>systemctl start acme-example.com.service</literal> + to generate fresh ones. + </para> + </section> + <section xml:id="module-security-acme-fix-jws"> + <title>Fixing JWS Verification error</title> + + <para> + It is possible that your account credentials file may become corrupt and need + to be regenerated. In this scenario lego will produce the error <literal>JWS verification error</literal>. + The solution is to simply delete the associated accounts file and + re-run the affected service(s). + </para> + +<programlisting> +# Find the accounts folder for the certificate +systemctl cat acme-example.com.service | grep -Po 'accounts/[^:]*' +export accountdir="$(!!)" +# Move this folder to some place else +mv /var/lib/acme/.lego/$accountdir{,.bak} +# Recreate the folder using systemd-tmpfiles +systemd-tmpfiles --create +# Get a new account and reissue certificates +# Note: Do this for all certs that share the same account email address +systemctl start acme-example.com.service +</programlisting> + + </section> +</chapter> diff --git a/nixos/modules/security/acme/mk-cert-ownership-assertion.nix b/nixos/modules/security/acme/mk-cert-ownership-assertion.nix new file mode 100644 index 00000000000..b80d89aeb9f --- /dev/null +++ b/nixos/modules/security/acme/mk-cert-ownership-assertion.nix @@ -0,0 +1,4 @@ +{ cert, group, groups, user }: { + assertion = cert.group == group || builtins.any (u: u == user) groups.${cert.group}.members; + message = "Group for certificate ${cert.domain} must be ${group}, or user ${user} must be a member of group ${cert.group}"; +} diff --git a/nixos/modules/security/apparmor.nix b/nixos/modules/security/apparmor.nix new file mode 100644 index 00000000000..be1b0362fc1 --- /dev/null +++ b/nixos/modules/security/apparmor.nix @@ -0,0 +1,216 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + inherit (builtins) attrNames head map match readFile; + inherit (lib) types; + inherit (config.environment) etc; + cfg = config.security.apparmor; + mkDisableOption = name: mkEnableOption name // { + default = true; + example = false; + }; + enabledPolicies = filterAttrs (n: p: p.enable) cfg.policies; +in + +{ + imports = [ + (mkRemovedOptionModule [ "security" "apparmor" "confineSUIDApplications" ] "Please use the new options: `security.apparmor.policies.<policy>.enable'.") + (mkRemovedOptionModule [ "security" "apparmor" "profiles" ] "Please use the new option: `security.apparmor.policies'.") + apparmor/includes.nix + apparmor/profiles.nix + ]; + + options = { + security.apparmor = { + enable = mkEnableOption '' + the AppArmor Mandatory Access Control system. + + If you're enabling this module on a running system, + note that a reboot will be required to activate AppArmor in the kernel. + + Also, beware that enabling this module privileges stability over security + by not trying to kill unconfined but newly confinable running processes by default, + though it would be needed because AppArmor can only confine new + or already confined processes of an executable. + This killing would for instance be necessary when upgrading to a NixOS revision + introducing for the first time an AppArmor profile for the executable + of a running process. + + Enable <xref linkend="opt-security.apparmor.killUnconfinedConfinables"/> + if you want this service to do such killing + by sending a <literal>SIGTERM</literal> to those running processes''; + policies = mkOption { + description = '' + AppArmor policies. + ''; + type = types.attrsOf (types.submodule ({ name, config, ... }: { + options = { + enable = mkDisableOption "loading of the profile into the kernel"; + enforce = mkDisableOption "enforcing of the policy or only complain in the logs"; + profile = mkOption { + description = "The policy of the profile."; + type = types.lines; + apply = pkgs.writeText name; + }; + }; + })); + default = {}; + }; + includes = mkOption { + type = types.attrsOf types.lines; + default = {}; + description = '' + List of paths to be added to AppArmor's searched paths + when resolving <literal>include</literal> directives. + ''; + apply = mapAttrs pkgs.writeText; + }; + packages = mkOption { + type = types.listOf types.package; + default = []; + description = "List of packages to be added to AppArmor's include path"; + }; + enableCache = mkEnableOption '' + caching of AppArmor policies + in <literal>/var/cache/apparmor/</literal>. + + Beware that AppArmor policies almost always contain Nix store paths, + and thus produce at each change of these paths + a new cached version accumulating in the cache''; + killUnconfinedConfinables = mkEnableOption '' + killing of processes which have an AppArmor profile enabled + (in <xref linkend="opt-security.apparmor.policies"/>) + but are not confined (because AppArmor can only confine new processes). + + This is only sending a gracious <literal>SIGTERM</literal> signal to the processes, + not a <literal>SIGKILL</literal>. + + Beware that due to a current limitation of AppArmor, + only profiles with exact paths (and no name) can enable such kills''; + }; + }; + + config = mkIf cfg.enable { + assertions = map (policy: + { assertion = match ".*/.*" policy == null; + message = "`security.apparmor.policies.\"${policy}\"' must not contain a slash."; + # Because, for instance, aa-remove-unknown uses profiles_names_list() in rc.apparmor.functions + # which does not recurse into sub-directories. + } + ) (attrNames cfg.policies); + + environment.systemPackages = [ + pkgs.apparmor-utils + pkgs.apparmor-bin-utils + ]; + environment.etc."apparmor.d".source = pkgs.linkFarm "apparmor.d" ( + # It's important to put only enabledPolicies here and not all cfg.policies + # because aa-remove-unknown reads profiles from all /etc/apparmor.d/* + mapAttrsToList (name: p: { inherit name; path = p.profile; }) enabledPolicies ++ + mapAttrsToList (name: path: { inherit name path; }) cfg.includes + ); + environment.etc."apparmor/parser.conf".text = '' + ${if cfg.enableCache then "write-cache" else "skip-cache"} + cache-loc /var/cache/apparmor + Include /etc/apparmor.d + '' + + concatMapStrings (p: "Include ${p}/etc/apparmor.d\n") cfg.packages; + # For aa-logprof + environment.etc."apparmor/apparmor.conf".text = '' + ''; + # For aa-logprof + environment.etc."apparmor/severity.db".source = pkgs.apparmor-utils + "/etc/apparmor/severity.db"; + environment.etc."apparmor/logprof.conf".source = pkgs.runCommand "logprof.conf" { + header = '' + [settings] + # /etc/apparmor.d/ is read-only on NixOS + profiledir = /var/cache/apparmor/logprof + inactive_profiledir = /etc/apparmor.d/disable + # Use: journalctl -b --since today --grep audit: | aa-logprof + logfiles = /dev/stdin + + parser = ${pkgs.apparmor-parser}/bin/apparmor_parser + ldd = ${pkgs.glibc.bin}/bin/ldd + logger = ${pkgs.util-linux}/bin/logger + + # customize how file ownership permissions are presented + # 0 - off + # 1 - default of what ever mode the log reported + # 2 - force the new permissions to be user + # 3 - force all perms on the rule to be user + default_owner_prompt = 1 + + custom_includes = /etc/apparmor.d ${concatMapStringsSep " " (p: "${p}/etc/apparmor.d") cfg.packages} + + [qualifiers] + ${pkgs.runtimeShell} = icnu + ${pkgs.bashInteractive}/bin/sh = icnu + ${pkgs.bashInteractive}/bin/bash = icnu + ${config.users.defaultUserShell} = icnu + ''; + footer = "${pkgs.apparmor-utils}/etc/apparmor/logprof.conf"; + passAsFile = [ "header" ]; + } '' + cp $headerPath $out + sed '1,/\[qualifiers\]/d' $footer >> $out + ''; + + boot.kernelParams = [ "apparmor=1" "security=apparmor" ]; + + systemd.services.apparmor = { + after = [ + "local-fs.target" + "systemd-journald-audit.socket" + ]; + before = [ "sysinit.target" ]; + wantedBy = [ "multi-user.target" ]; + unitConfig = { + Description="Load AppArmor policies"; + DefaultDependencies = "no"; + ConditionSecurity = "apparmor"; + }; + # Reloading instead of restarting enables to load new AppArmor profiles + # without necessarily restarting all services which have Requires=apparmor.service + reloadIfChanged = true; + restartTriggers = [ + etc."apparmor/parser.conf".source + etc."apparmor.d".source + ]; + serviceConfig = let + killUnconfinedConfinables = pkgs.writeShellScript "apparmor-kill" '' + set -eu + ${pkgs.apparmor-bin-utils}/bin/aa-status --json | + ${pkgs.jq}/bin/jq --raw-output '.processes | .[] | .[] | select (.status == "unconfined") | .pid' | + xargs --verbose --no-run-if-empty --delimiter='\n' \ + kill + ''; + commonOpts = p: "--verbose --show-cache ${optionalString (!p.enforce) "--complain "}${p.profile}"; + in { + Type = "oneshot"; + RemainAfterExit = "yes"; + ExecStartPre = "${pkgs.apparmor-utils}/bin/aa-teardown"; + ExecStart = mapAttrsToList (n: p: "${pkgs.apparmor-parser}/bin/apparmor_parser --add ${commonOpts p}") enabledPolicies; + ExecStartPost = optional cfg.killUnconfinedConfinables killUnconfinedConfinables; + ExecReload = + # Add or replace into the kernel profiles in enabledPolicies + # (because AppArmor can do that without stopping the processes already confined). + mapAttrsToList (n: p: "${pkgs.apparmor-parser}/bin/apparmor_parser --replace ${commonOpts p}") enabledPolicies ++ + # Remove from the kernel any profile whose name is not + # one of the names within the content of the profiles in enabledPolicies + # (indirectly read from /etc/apparmor.d/*, without recursing into sub-directory). + # Note that this does not remove profiles dynamically generated by libvirt. + [ "${pkgs.apparmor-utils}/bin/aa-remove-unknown" ] ++ + # Optionaly kill the processes which are unconfined but now have a profile loaded + # (because AppArmor can only start to confine new processes). + optional cfg.killUnconfinedConfinables killUnconfinedConfinables; + ExecStop = "${pkgs.apparmor-utils}/bin/aa-teardown"; + CacheDirectory = [ "apparmor" "apparmor/logprof" ]; + CacheDirectoryMode = "0700"; + }; + }; + }; + + meta.maintainers = with maintainers; [ julm ]; +} diff --git a/nixos/modules/security/apparmor/includes.nix b/nixos/modules/security/apparmor/includes.nix new file mode 100644 index 00000000000..f290e95a296 --- /dev/null +++ b/nixos/modules/security/apparmor/includes.nix @@ -0,0 +1,317 @@ +{ config, lib, pkgs, ... }: +let + inherit (builtins) attrNames hasAttr isAttrs; + inherit (lib) getLib; + inherit (config.environment) etc; + # Utility to generate an AppArmor rule + # only when the given path exists in config.environment.etc + etcRule = arg: + let go = { path ? null, mode ? "r", trail ? "" }: + lib.optionalString (hasAttr path etc) + "${mode} ${config.environment.etc.${path}.source}${trail},"; + in if isAttrs arg + then go arg + else go { path = arg; }; +in +{ +# FIXME: most of the etcRule calls below have been +# written systematically by converting from apparmor-profiles's profiles +# without testing nor deep understanding of their uses, +# and thus may need more rules or can have less rules; +# this remains to be determined case by case, +# some may even be completely useless. +config.security.apparmor.includes = { + # This one is included by <tunables/global> + # which is usualy included before any profile. + "abstractions/tunables/alias" = '' + alias /bin -> /run/current-system/sw/bin, + alias /lib/modules -> /run/current-system/kernel/lib/modules, + alias /sbin -> /run/current-system/sw/sbin, + alias /usr -> /run/current-system/sw, + ''; + "abstractions/audio" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/audio" + '' + lib.concatMapStringsSep "\n" etcRule [ + "asound.conf" + "esound/esd.conf" + "libao.conf" + { path = "pulse"; trail = "/"; } + { path = "pulse"; trail = "/**"; } + { path = "sound"; trail = "/"; } + { path = "sound"; trail = "/**"; } + { path = "alsa/conf.d"; trail = "/"; } + { path = "alsa/conf.d"; trail = "/*"; } + "openal/alsoft.conf" + "wildmidi/wildmidi.conf" + ]; + "abstractions/authentication" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/authentication" + # Defined in security.pam + include <abstractions/pam> + '' + lib.concatMapStringsSep "\n" etcRule [ + "nologin" + "securetty" + { path = "security"; trail = "/*"; } + "shadow" + "gshadow" + "pwdb.conf" + "default/passwd" + "login.defs" + ]; + "abstractions/base" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/base" + r ${pkgs.stdenv.cc.libc}/share/locale/**, + r ${pkgs.stdenv.cc.libc}/share/locale.alias, + ${lib.optionalString (pkgs.glibcLocales != null) "r ${pkgs.glibcLocales}/lib/locale/locale-archive,"} + ${etcRule "localtime"} + r ${pkgs.tzdata}/share/zoneinfo/**, + r ${pkgs.stdenv.cc.libc}/share/i18n/**, + ''; + "abstractions/bash" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/bash" + + # bash inspects filesystems at startup + # and /etc/mtab is linked to /proc/mounts + @{PROC}/mounts + + # system-wide bash configuration + '' + lib.concatMapStringsSep "\n" etcRule [ + "profile.dos" + "profile" + "profile.d" + { path = "profile.d"; trail = "/*"; } + "bashrc" + "bash.bashrc" + "bash.bashrc.local" + "bash_completion" + "bash_completion.d" + { path = "bash_completion.d"; trail = "/*"; } + # bash relies on system-wide readline configuration + "inputrc" + # run out of /etc/bash.bashrc + "DIR_COLORS" + ]; + "abstractions/consoles" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/consoles" + ''; + "abstractions/cups-client" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/cups-client" + ${etcRule "cups/cups-client.conf"} + ''; + "abstractions/dbus-session-strict" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/dbus-session-strict" + ${etcRule "machine-id"} + ''; + "abstractions/dconf" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/dconf" + ${etcRule { path = "dconf"; trail = "/**"; }} + ''; + "abstractions/dri-common" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/dri-common" + ${etcRule "drirc"} + ''; + # The config.fonts.fontconfig NixOS module adds many files to /etc/fonts/ + # by symlinking them but without exporting them outside of its NixOS module, + # those are therefore added there to this "abstractions/fonts". + "abstractions/fonts" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/fonts" + ${etcRule { path = "fonts"; trail = "/**"; }} + ''; + "abstractions/gnome" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/gnome" + include <abstractions/fonts> + '' + lib.concatMapStringsSep "\n" etcRule [ + { path = "gnome"; trail = "/gtkrc*"; } + { path = "gtk"; trail = "/*"; } + { path = "gtk-2.0"; trail = "/*"; } + { path = "gtk-3.0"; trail = "/*"; } + "orbitrc" + { path = "pango"; trail = "/*"; } + { path = "/etc/gnome-vfs-2.0"; trail = "/modules/"; } + { path = "/etc/gnome-vfs-2.0"; trail = "/modules/*"; } + "papersize" + { path = "cups"; trail = "/lpoptions"; } + { path = "gnome"; trail = "/defaults.list"; } + { path = "xdg"; trail = "/{,*-}mimeapps.list"; } + "xdg/mimeapps.list" + ]; + "abstractions/kde" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/kde" + '' + lib.concatMapStringsSep "\n" etcRule [ + { path = "qt3"; trail = "/kstylerc"; } + { path = "qt3"; trail = "/qt_plugins_3.3rc"; } + { path = "qt3"; trail = "/qtrc"; } + "kderc" + { path = "kde3"; trail = "/*"; } + "kde4rc" + { path = "xdg"; trail = "/kdeglobals"; } + { path = "xdg"; trail = "/Trolltech.conf"; } + ]; + "abstractions/kerberosclient" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/kerberosclient" + '' + lib.concatMapStringsSep "\n" etcRule [ + { path = "krb5.keytab"; mode="rk"; } + "krb5.conf" + "krb5.conf.d" + { path = "krb5.conf.d"; trail = "/*"; } + + # config files found via strings on libs + "krb.conf" + "krb.realms" + "srvtab" + ]; + "abstractions/ldapclient" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/ldapclient" + '' + lib.concatMapStringsSep "\n" etcRule [ + "ldap.conf" + "ldap.secret" + { path = "openldap"; trail = "/*"; } + { path = "openldap"; trail = "/cacerts/*"; } + { path = "sasl2"; trail = "/*"; } + ]; + "abstractions/likewise" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/likewise" + ''; + "abstractions/mdns" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/mdns" + ${etcRule "nss_mdns.conf"} + ''; + "abstractions/nameservice" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/nameservice" + + # Many programs wish to perform nameservice-like operations, such as + # looking up users by name or id, groups by name or id, hosts by name + # or IP, etc. These operations may be performed through files, dns, + # NIS, NIS+, LDAP, hesiod, wins, etc. Allow them all here. + mr ${getLib pkgs.nss}/lib/libnss_*.so*, + mr ${getLib pkgs.nss}/lib64/libnss_*.so*, + '' + lib.concatMapStringsSep "\n" etcRule [ + "group" + "host.conf" + "hosts" + "nsswitch.conf" + "gai.conf" + "passwd" + "protocols" + + # libtirpc (used for NIS/YP login) needs this + "netconfig" + + "resolv.conf" + + { path = "samba"; trail = "/lmhosts"; } + "services" + + "default/nss" + + # libnl-3-200 via libnss-gw-name + { path = "libnl"; trail = "/classid"; } + { path = "libnl-3"; trail = "/classid"; } + ]; + "abstractions/nis" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/nis" + ''; + "abstractions/nvidia" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/nvidia" + ${etcRule "vdpau_wrapper.cfg"} + ''; + "abstractions/opencl-common" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/opencl-common" + ${etcRule { path = "OpenCL"; trail = "/**"; }} + ''; + "abstractions/opencl-mesa" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/opencl-mesa" + ${etcRule "default/drirc"} + ''; + "abstractions/openssl" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/openssl" + ${etcRule { path = "ssl"; trail = "/openssl.cnf"; }} + ''; + "abstractions/p11-kit" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/p11-kit" + '' + lib.concatMapStringsSep "\n" etcRule [ + { path = "pkcs11"; trail = "/"; } + { path = "pkcs11"; trail = "/pkcs11.conf"; } + { path = "pkcs11"; trail = "/modules/"; } + { path = "pkcs11"; trail = "/modules/*"; } + ]; + "abstractions/perl" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/perl" + ${etcRule { path = "perl"; trail = "/**"; }} + ''; + "abstractions/php" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/php" + '' + lib.concatMapStringsSep "\n" etcRule [ + { path = "php"; trail = "/**/"; } + { path = "php5"; trail = "/**/"; } + { path = "php7"; trail = "/**/"; } + { path = "php"; trail = "/**.ini"; } + { path = "php5"; trail = "/**.ini"; } + { path = "php7"; trail = "/**.ini"; } + ]; + "abstractions/postfix-common" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/postfix-common" + '' + lib.concatMapStringsSep "\n" etcRule [ + "mailname" + { path = "postfix"; trail = "/*.cf"; } + "postfix/main.cf" + "postfix/master.cf" + ]; + "abstractions/python" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/python" + ''; + "abstractions/qt5" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/qt5" + '' + lib.concatMapStringsSep "\n" etcRule [ + { path = "xdg"; trail = "/QtProject/qtlogging.ini"; } + { path = "xdg/QtProject"; trail = "/qtlogging.ini"; } + "xdg/QtProject/qtlogging.ini" + ]; + "abstractions/samba" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/samba" + ${etcRule { path = "samba"; trail = "/*"; }} + ''; + "abstractions/ssl_certs" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/ssl_certs" + + # For the NixOS module: security.acme + r /var/lib/acme/*/cert.pem, + r /var/lib/acme/*/chain.pem, + r /var/lib/acme/*/fullchain.pem, + + '' + lib.concatMapStringsSep "\n" etcRule [ + "ssl/certs/ca-certificates.crt" + "ssl/certs/ca-bundle.crt" + "pki/tls/certs/ca-bundle.crt" + + { path = "ssl/trust"; trail = "/"; } + { path = "ssl/trust"; trail = "/*"; } + { path = "ssl/trust/anchors"; trail = "/"; } + { path = "ssl/trust/anchors"; trail = "/**"; } + { path = "pki/trust"; trail = "/"; } + { path = "pki/trust"; trail = "/*"; } + { path = "pki/trust/anchors"; trail = "/"; } + { path = "pki/trust/anchors"; trail = "/**"; } + ]; + "abstractions/ssl_keys" = '' + # security.acme NixOS module + r /var/lib/acme/*/full.pem, + r /var/lib/acme/*/key.pem, + ''; + "abstractions/vulkan" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/vulkan" + ${etcRule { path = "vulkan/icd.d"; trail = "/"; }} + ${etcRule { path = "vulkan/icd.d"; trail = "/*.json"; }} + ''; + "abstractions/winbind" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/winbind" + ${etcRule { path = "samba"; trail = "/smb.conf"; }} + ${etcRule { path = "samba"; trail = "/dhcp.conf"; }} + ''; + "abstractions/X" = '' + include "${pkgs.apparmor-profiles}/etc/apparmor.d/abstractions/X" + ${etcRule { path = "X11/cursors"; trail = "/"; }} + ${etcRule { path = "X11/cursors"; trail = "/**"; }} + ''; +}; +} diff --git a/nixos/modules/security/apparmor/profiles.nix b/nixos/modules/security/apparmor/profiles.nix new file mode 100644 index 00000000000..8eb630b5a48 --- /dev/null +++ b/nixos/modules/security/apparmor/profiles.nix @@ -0,0 +1,11 @@ +{ config, lib, pkgs, ... }: +let apparmor = config.security.apparmor; in +{ +config.security.apparmor.packages = [ pkgs.apparmor-profiles ]; +config.security.apparmor.policies."bin.ping".profile = lib.mkIf apparmor.policies."bin.ping".enable '' + include "${pkgs.iputils.apparmor}/bin.ping" + include "${pkgs.inetutils.apparmor}/bin.ping" + # Note that including those two profiles in the same profile + # would not work if the second one were to re-include <tunables/global>. +''; +} diff --git a/nixos/modules/security/audit.nix b/nixos/modules/security/audit.nix new file mode 100644 index 00000000000..2b22bdd9f0a --- /dev/null +++ b/nixos/modules/security/audit.nix @@ -0,0 +1,123 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.security.audit; + enabled = cfg.enable == "lock" || cfg.enable; + + failureModes = { + silent = 0; + printk = 1; + panic = 2; + }; + + disableScript = pkgs.writeScript "audit-disable" '' + #!${pkgs.runtimeShell} -eu + # Explicitly disable everything, as otherwise journald might start it. + auditctl -D + auditctl -e 0 -a task,never + ''; + + # TODO: it seems like people like their rules to be somewhat secret, yet they will not be if + # put in the store like this. At the same time, it doesn't feel like a huge deal and working + # around that is a pain so I'm leaving it like this for now. + startScript = pkgs.writeScript "audit-start" '' + #!${pkgs.runtimeShell} -eu + # Clear out any rules we may start with + auditctl -D + + # Put the rules in a temporary file owned and only readable by root + rulesfile="$(mktemp)" + ${concatMapStrings (x: "echo '${x}' >> $rulesfile\n") cfg.rules} + + # Apply the requested rules + auditctl -R "$rulesfile" + + # Enable and configure auditing + auditctl \ + -e ${if cfg.enable == "lock" then "2" else "1"} \ + -b ${toString cfg.backlogLimit} \ + -f ${toString failureModes.${cfg.failureMode}} \ + -r ${toString cfg.rateLimit} + ''; + + stopScript = pkgs.writeScript "audit-stop" '' + #!${pkgs.runtimeShell} -eu + # Clear the rules + auditctl -D + + # Disable auditing + auditctl -e 0 + ''; +in { + options = { + security.audit = { + enable = mkOption { + type = types.enum [ false true "lock" ]; + default = false; + description = '' + Whether to enable the Linux audit system. The special `lock' value can be used to + enable auditing and prevent disabling it until a restart. Be careful about locking + this, as it will prevent you from changing your audit configuration until you + restart. If possible, test your configuration using build-vm beforehand. + ''; + }; + + failureMode = mkOption { + type = types.enum [ "silent" "printk" "panic" ]; + default = "printk"; + description = "How to handle critical errors in the auditing system"; + }; + + backlogLimit = mkOption { + type = types.int; + default = 64; # Apparently the kernel default + description = '' + The maximum number of outstanding audit buffers allowed; exceeding this is + considered a failure and handled in a manner specified by failureMode. + ''; + }; + + rateLimit = mkOption { + type = types.int; + default = 0; + description = '' + The maximum messages per second permitted before triggering a failure as + specified by failureMode. Setting it to zero disables the limit. + ''; + }; + + rules = mkOption { + type = types.listOf types.str; # (types.either types.str (types.submodule rule)); + default = []; + example = [ "-a exit,always -F arch=b64 -S execve" ]; + description = '' + The ordered audit rules, with each string appearing as one line of the audit.rules file. + ''; + }; + }; + }; + + config = { + systemd.services.audit = { + description = "Kernel Auditing"; + wantedBy = [ "basic.target" ]; + + unitConfig = { + ConditionVirtualization = "!container"; + ConditionSecurity = [ "audit" ]; + }; + + + path = [ pkgs.audit ]; + + serviceConfig = { + Type = "oneshot"; + RemainAfterExit = true; + ExecStart = "@${if enabled then startScript else disableScript} audit-start"; + ExecStop = "@${stopScript} audit-stop"; + }; + }; + }; +} diff --git a/nixos/modules/security/auditd.nix b/nixos/modules/security/auditd.nix new file mode 100644 index 00000000000..9d26cfbcfb1 --- /dev/null +++ b/nixos/modules/security/auditd.nix @@ -0,0 +1,31 @@ +{ config, lib, pkgs, ... }: + +with lib; + +{ + options.security.auditd.enable = mkEnableOption "the Linux Audit daemon"; + + config = mkIf config.security.auditd.enable { + boot.kernelParams = [ "audit=1" ]; + + environment.systemPackages = [ pkgs.audit ]; + + systemd.services.auditd = { + description = "Linux Audit daemon"; + wantedBy = [ "basic.target" ]; + + unitConfig = { + ConditionVirtualization = "!container"; + ConditionSecurity = [ "audit" ]; + DefaultDependencies = false; + }; + + path = [ pkgs.audit ]; + + serviceConfig = { + ExecStartPre="${pkgs.coreutils}/bin/mkdir -p /var/log/audit"; + ExecStart = "${pkgs.audit}/bin/auditd -l -n -s nochange"; + }; + }; + }; +} diff --git a/nixos/modules/security/ca.nix b/nixos/modules/security/ca.nix new file mode 100644 index 00000000000..f71d9d90ec5 --- /dev/null +++ b/nixos/modules/security/ca.nix @@ -0,0 +1,89 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.security.pki; + + cacertPackage = pkgs.cacert.override { + blacklist = cfg.caCertificateBlacklist; + extraCertificateFiles = cfg.certificateFiles; + extraCertificateStrings = cfg.certificates; + }; + caBundle = "${cacertPackage}/etc/ssl/certs/ca-bundle.crt"; + +in + +{ + + options = { + + security.pki.certificateFiles = mkOption { + type = types.listOf types.path; + default = []; + example = literalExpression ''[ "''${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" ]''; + description = '' + A list of files containing trusted root certificates in PEM + format. These are concatenated to form + <filename>/etc/ssl/certs/ca-certificates.crt</filename>, which is + used by many programs that use OpenSSL, such as + <command>curl</command> and <command>git</command>. + ''; + }; + + security.pki.certificates = mkOption { + type = types.listOf types.str; + default = []; + example = literalExpression '' + [ ''' + NixOS.org + ========= + -----BEGIN CERTIFICATE----- + MIIGUDCCBTigAwIBAgIDD8KWMA0GCSqGSIb3DQEBBQUAMIGMMQswCQYDVQQGEwJJ + TDEWMBQGA1UEChMNU3RhcnRDb20gTHRkLjErMCkGA1UECxMiU2VjdXJlIERpZ2l0 + ... + -----END CERTIFICATE----- + ''' + ] + ''; + description = '' + A list of trusted root certificates in PEM format. + ''; + }; + + security.pki.caCertificateBlacklist = mkOption { + type = types.listOf types.str; + default = []; + example = [ + "WoSign" "WoSign China" + "CA WoSign ECC Root" + "Certification Authority of WoSign G2" + ]; + description = '' + A list of blacklisted CA certificate names that won't be imported from + the Mozilla Trust Store into + <filename>/etc/ssl/certs/ca-certificates.crt</filename>. Use the + names from that file. + ''; + }; + + }; + + config = { + + # NixOS canonical location + Debian/Ubuntu/Arch/Gentoo compatibility. + environment.etc."ssl/certs/ca-certificates.crt".source = caBundle; + + # Old NixOS compatibility. + environment.etc."ssl/certs/ca-bundle.crt".source = caBundle; + + # CentOS/Fedora compatibility. + environment.etc."pki/tls/certs/ca-bundle.crt".source = caBundle; + + # P11-Kit trust source. + environment.etc."ssl/trust-source".source = "${cacertPackage.p11kit}/etc/ssl/trust-source"; + + }; + +} diff --git a/nixos/modules/security/chromium-suid-sandbox.nix b/nixos/modules/security/chromium-suid-sandbox.nix new file mode 100644 index 00000000000..bb99c053f71 --- /dev/null +++ b/nixos/modules/security/chromium-suid-sandbox.nix @@ -0,0 +1,38 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.security.chromiumSuidSandbox; + sandbox = pkgs.chromium.sandbox; +in +{ + imports = [ + (mkRenamedOptionModule [ "programs" "unity3d" "enable" ] [ "security" "chromiumSuidSandbox" "enable" ]) + ]; + + options.security.chromiumSuidSandbox.enable = mkOption { + type = types.bool; + default = false; + description = '' + Whether to install the Chromium SUID sandbox which is an executable that + Chromium may use in order to achieve sandboxing. + + If you get the error "The SUID sandbox helper binary was found, but is not + configured correctly.", turning this on might help. + + Also, if the URL chrome://sandbox tells you that "You are not adequately + sandboxed!", turning this on might resolve the issue. + ''; + }; + + config = mkIf cfg.enable { + environment.systemPackages = [ sandbox ]; + security.wrappers.${sandbox.passthru.sandboxExecutableName} = + { setuid = true; + owner = "root"; + group = "root"; + source = "${sandbox}/bin/${sandbox.passthru.sandboxExecutableName}"; + }; + }; +} diff --git a/nixos/modules/security/dhparams.nix b/nixos/modules/security/dhparams.nix new file mode 100644 index 00000000000..cfa9003f12f --- /dev/null +++ b/nixos/modules/security/dhparams.nix @@ -0,0 +1,177 @@ +{ config, lib, options, pkgs, ... }: + +let + inherit (lib) literalExpression mkOption types; + cfg = config.security.dhparams; + opt = options.security.dhparams; + + bitType = types.addCheck types.int (b: b >= 16) // { + name = "bits"; + description = "integer of at least 16 bits"; + }; + + paramsSubmodule = { name, config, ... }: { + options.bits = mkOption { + type = bitType; + default = cfg.defaultBitSize; + defaultText = literalExpression "config.${opt.defaultBitSize}"; + description = '' + The bit size for the prime that is used during a Diffie-Hellman + key exchange. + ''; + }; + + options.path = mkOption { + type = types.path; + readOnly = true; + description = '' + The resulting path of the generated Diffie-Hellman parameters + file for other services to reference. This could be either a + store path or a file inside the directory specified by + <option>security.dhparams.path</option>. + ''; + }; + + config.path = let + generated = pkgs.runCommand "dhparams-${name}.pem" { + nativeBuildInputs = [ pkgs.openssl ]; + } "openssl dhparam -out \"$out\" ${toString config.bits}"; + in if cfg.stateful then "${cfg.path}/${name}.pem" else generated; + }; + +in { + options = { + security.dhparams = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Whether to generate new DH params and clean up old DH params. + ''; + }; + + params = mkOption { + type = with types; let + coerce = bits: { inherit bits; }; + in attrsOf (coercedTo int coerce (submodule paramsSubmodule)); + default = {}; + example = lib.literalExpression "{ nginx.bits = 3072; }"; + description = '' + Diffie-Hellman parameters to generate. + + The value is the size (in bits) of the DH params to generate. The + generated DH params path can be found in + <literal>config.security.dhparams.params.<replaceable>name</replaceable>.path</literal>. + + <note><para>The name of the DH params is taken as being the name of + the service it serves and the params will be generated before the + said service is started.</para></note> + + <warning><para>If you are removing all dhparams from this list, you + have to leave <option>security.dhparams.enable</option> for at + least one activation in order to have them be cleaned up. This also + means if you rollback to a version without any dhparams the + existing ones won't be cleaned up. Of course this only applies if + <option>security.dhparams.stateful</option> is + <literal>true</literal>.</para></warning> + + <note><title>For module implementers:</title><para>It's recommended + to not set a specific bit size here, so that users can easily + override this by setting + <option>security.dhparams.defaultBitSize</option>.</para></note> + ''; + }; + + stateful = mkOption { + type = types.bool; + default = true; + description = '' + Whether generation of Diffie-Hellman parameters should be stateful or + not. If this is enabled, PEM-encoded files for Diffie-Hellman + parameters are placed in the directory specified by + <option>security.dhparams.path</option>. Otherwise the files are + created within the Nix store. + + <note><para>If this is <literal>false</literal> the resulting store + path will be non-deterministic and will be rebuilt every time the + <package>openssl</package> package changes.</para></note> + ''; + }; + + defaultBitSize = mkOption { + type = bitType; + default = 2048; + description = '' + This allows to override the default bit size for all of the + Diffie-Hellman parameters set in + <option>security.dhparams.params</option>. + ''; + }; + + path = mkOption { + type = types.str; + default = "/var/lib/dhparams"; + description = '' + Path to the directory in which Diffie-Hellman parameters will be + stored. This only is relevant if + <option>security.dhparams.stateful</option> is + <literal>true</literal>. + ''; + }; + }; + }; + + config = lib.mkIf (cfg.enable && cfg.stateful) { + systemd.services = { + dhparams-init = { + description = "Clean Up Old Diffie-Hellman Parameters"; + + # Clean up even when no DH params is set + wantedBy = [ "multi-user.target" ]; + + serviceConfig.RemainAfterExit = true; + serviceConfig.Type = "oneshot"; + + script = '' + if [ ! -d ${cfg.path} ]; then + mkdir -p ${cfg.path} + fi + + # Remove old dhparams + for file in ${cfg.path}/*; do + if [ ! -f "$file" ]; then + continue + fi + ${lib.concatStrings (lib.mapAttrsToList (name: { bits, path, ... }: '' + if [ "$file" = ${lib.escapeShellArg path} ] && \ + ${pkgs.openssl}/bin/openssl dhparam -in "$file" -text \ + | head -n 1 | grep "(${toString bits} bit)" > /dev/null; then + continue + fi + '') cfg.params)} + rm $file + done + + # TODO: Ideally this would be removing the *former* cfg.path, though + # this does not seem really important as changes to it are quite + # unlikely + rmdir --ignore-fail-on-non-empty ${cfg.path} + ''; + }; + } // lib.mapAttrs' (name: { bits, path, ... }: lib.nameValuePair "dhparams-gen-${name}" { + description = "Generate Diffie-Hellman Parameters for ${name}"; + after = [ "dhparams-init.service" ]; + before = [ "${name}.service" ]; + wantedBy = [ "multi-user.target" ]; + unitConfig.ConditionPathExists = "!${path}"; + serviceConfig.Type = "oneshot"; + script = '' + mkdir -p ${lib.escapeShellArg cfg.path} + ${pkgs.openssl}/bin/openssl dhparam -out ${lib.escapeShellArg path} \ + ${toString bits} + ''; + }) cfg.params; + }; + + meta.maintainers = with lib.maintainers; [ ekleog ]; +} diff --git a/nixos/modules/security/doas.nix b/nixos/modules/security/doas.nix new file mode 100644 index 00000000000..2a814f17e45 --- /dev/null +++ b/nixos/modules/security/doas.nix @@ -0,0 +1,288 @@ +{ config, lib, pkgs, ... }: + +with lib; +let + cfg = config.security.doas; + + inherit (pkgs) doas; + + mkUsrString = user: toString user; + + mkGrpString = group: ":${toString group}"; + + mkOpts = rule: concatStringsSep " " [ + (optionalString rule.noPass "nopass") + (optionalString rule.noLog "nolog") + (optionalString rule.persist "persist") + (optionalString rule.keepEnv "keepenv") + "setenv { SSH_AUTH_SOCK TERMINFO TERMINFO_DIRS ${concatStringsSep " " rule.setEnv} }" + ]; + + mkArgs = rule: + if (isNull rule.args) then "" + else if (length rule.args == 0) then "args" + else "args ${concatStringsSep " " rule.args}"; + + mkRule = rule: + let + opts = mkOpts rule; + + as = optionalString (!isNull rule.runAs) "as ${rule.runAs}"; + + cmd = optionalString (!isNull rule.cmd) "cmd ${rule.cmd}"; + + args = mkArgs rule; + in + optionals (length cfg.extraRules > 0) [ + ( + optionalString (length rule.users > 0) + (map (usr: "permit ${opts} ${mkUsrString usr} ${as} ${cmd} ${args}") rule.users) + ) + ( + optionalString (length rule.groups > 0) + (map (grp: "permit ${opts} ${mkGrpString grp} ${as} ${cmd} ${args}") rule.groups) + ) + ]; +in +{ + + ###### interface + + options.security.doas = { + + enable = mkOption { + type = with types; bool; + default = false; + description = '' + Whether to enable the <command>doas</command> command, which allows + non-root users to execute commands as root. + ''; + }; + + wheelNeedsPassword = mkOption { + type = with types; bool; + default = true; + description = '' + Whether users of the <code>wheel</code> group must provide a password to + run commands as super user via <command>doas</command>. + ''; + }; + + extraRules = mkOption { + default = []; + description = '' + Define specific rules to be set in the + <filename>/etc/doas.conf</filename> file. More specific rules should + come after more general ones in order to yield the expected behavior. + You can use <code>mkBefore</code> and/or <code>mkAfter</code> to ensure + this is the case when configuration options are merged. + ''; + example = literalExpression '' + [ + # Allow execution of any command by any user in group doas, requiring + # a password and keeping any previously-defined environment variables. + { groups = [ "doas" ]; noPass = false; keepEnv = true; } + + # Allow execution of "/home/root/secret.sh" by user `backup` OR user + # `database` OR any member of the group with GID `1006`, without a + # password. + { users = [ "backup" "database" ]; groups = [ 1006 ]; + cmd = "/home/root/secret.sh"; noPass = true; } + + # Allow any member of group `bar` to run `/home/baz/cmd1.sh` as user + # `foo` with argument `hello-doas`. + { groups = [ "bar" ]; runAs = "foo"; + cmd = "/home/baz/cmd1.sh"; args = [ "hello-doas" ]; } + + # Allow any member of group `bar` to run `/home/baz/cmd2.sh` as user + # `foo` with no arguments. + { groups = [ "bar" ]; runAs = "foo"; + cmd = "/home/baz/cmd2.sh"; args = [ ]; } + + # Allow user `abusers` to execute "nano" and unset the value of + # SSH_AUTH_SOCK, override the value of ALPHA to 1, and inherit the + # value of BETA from the current environment. + { users = [ "abusers" ]; cmd = "nano"; + setEnv = [ "-SSH_AUTH_SOCK" "ALPHA=1" "BETA" ]; } + ] + ''; + type = with types; listOf ( + submodule { + options = { + + noPass = mkOption { + type = with types; bool; + default = false; + description = '' + If <code>true</code>, the user is not required to enter a + password. + ''; + }; + + noLog = mkOption { + type = with types; bool; + default = false; + description = '' + If <code>true</code>, successful executions will not be logged + to + <citerefentry><refentrytitle>syslogd</refentrytitle><manvolnum>8</manvolnum></citerefentry>. + ''; + }; + + persist = mkOption { + type = with types; bool; + default = false; + description = '' + If <code>true</code>, do not ask for a password again for some + time after the user successfully authenticates. + ''; + }; + + keepEnv = mkOption { + type = with types; bool; + default = false; + description = '' + If <code>true</code>, environment variables other than those + listed in + <citerefentry><refentrytitle>doas</refentrytitle><manvolnum>1</manvolnum></citerefentry> + are kept when creating the environment for the new process. + ''; + }; + + setEnv = mkOption { + type = with types; listOf str; + default = []; + description = '' + Keep or set the specified variables. Variables may also be + removed with a leading '-' or set using + <code>variable=value</code>. If the first character of + <code>value</code> is a '$', the value to be set is taken from + the existing environment variable of the indicated name. This + option is processed after the default environment has been + created. + + NOTE: All rules have <code>setenv { SSH_AUTH_SOCK }</code> by + default. To prevent <code>SSH_AUTH_SOCK</code> from being + inherited, add <code>"-SSH_AUTH_SOCK"</code> anywhere in this + list. + ''; + }; + + users = mkOption { + type = with types; listOf (either str int); + default = []; + description = "The usernames / UIDs this rule should apply for."; + }; + + groups = mkOption { + type = with types; listOf (either str int); + default = []; + description = "The groups / GIDs this rule should apply for."; + }; + + runAs = mkOption { + type = with types; nullOr str; + default = null; + description = '' + Which user or group the specified command is allowed to run as. + When set to <code>null</code> (the default), all users are + allowed. + + A user can be specified using just the username: + <code>"foo"</code>. It is also possible to only allow running as + a specific group with <code>":bar"</code>. + ''; + }; + + cmd = mkOption { + type = with types; nullOr str; + default = null; + description = '' + The command the user is allowed to run. When set to + <code>null</code> (the default), all commands are allowed. + + NOTE: It is best practice to specify absolute paths. If a + relative path is specified, only a restricted PATH will be + searched. + ''; + }; + + args = mkOption { + type = with types; nullOr (listOf str); + default = null; + description = '' + Arguments that must be provided to the command. When set to + <code>[]</code>, the command must be run without any arguments. + ''; + }; + }; + } + ); + }; + + extraConfig = mkOption { + type = with types; lines; + default = ""; + description = '' + Extra configuration text appended to <filename>doas.conf</filename>. + ''; + }; + }; + + + ###### implementation + + config = mkIf cfg.enable { + + security.doas.extraRules = mkOrder 600 [ + { + groups = [ "wheel" ]; + noPass = !cfg.wheelNeedsPassword; + } + ]; + + security.wrappers.doas = + { setuid = true; + owner = "root"; + group = "root"; + source = "${doas}/bin/doas"; + }; + + environment.systemPackages = [ + doas + ]; + + security.pam.services.doas = { + allowNullPassword = true; + sshAgentAuth = true; + }; + + environment.etc."doas.conf" = { + source = pkgs.runCommand "doas-conf" + { + src = pkgs.writeText "doas-conf-in" '' + # To modify this file, set the NixOS options + # `security.doas.extraRules` or `security.doas.extraConfig`. To + # completely replace the contents of this file, use + # `environment.etc."doas.conf"`. + + # "root" is allowed to do anything. + permit nopass keepenv root + + # extraRules + ${concatStringsSep "\n" (lists.flatten (map mkRule cfg.extraRules))} + + # extraConfig + ${cfg.extraConfig} + ''; + preferLocalBuild = true; + } + # Make sure that the doas.conf file is syntactically valid. + "${pkgs.buildPackages.doas}/bin/doas -C $src && cp $src $out"; + mode = "0440"; + }; + + }; + + meta.maintainers = with maintainers; [ cole-h ]; +} diff --git a/nixos/modules/security/duosec.nix b/nixos/modules/security/duosec.nix new file mode 100644 index 00000000000..bbe246fe229 --- /dev/null +++ b/nixos/modules/security/duosec.nix @@ -0,0 +1,240 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.security.duosec; + + boolToStr = b: if b then "yes" else "no"; + + configFilePam = '' + [duo] + ikey=${cfg.integrationKey} + host=${cfg.host} + ${optionalString (cfg.groups != "") ("groups="+cfg.groups)} + failmode=${cfg.failmode} + pushinfo=${boolToStr cfg.pushinfo} + autopush=${boolToStr cfg.autopush} + prompts=${toString cfg.prompts} + fallback_local_ip=${boolToStr cfg.fallbackLocalIP} + ''; + + configFileLogin = configFilePam + '' + motd=${boolToStr cfg.motd} + accept_env_factor=${boolToStr cfg.acceptEnvFactor} + ''; +in +{ + imports = [ + (mkRenamedOptionModule [ "security" "duosec" "group" ] [ "security" "duosec" "groups" ]) + (mkRenamedOptionModule [ "security" "duosec" "ikey" ] [ "security" "duosec" "integrationKey" ]) + (mkRemovedOptionModule [ "security" "duosec" "skey" ] "The insecure security.duosec.skey option has been replaced by a new security.duosec.secretKeyFile option. Use this new option to store a secure copy of your key instead.") + ]; + + options = { + security.duosec = { + ssh.enable = mkOption { + type = types.bool; + default = false; + description = "If enabled, protect SSH logins with Duo Security."; + }; + + pam.enable = mkOption { + type = types.bool; + default = false; + description = "If enabled, protect logins with Duo Security using PAM support."; + }; + + integrationKey = mkOption { + type = types.str; + description = "Integration key."; + }; + + secretKeyFile = mkOption { + type = types.nullOr types.path; + default = null; + description = '' + A file containing your secret key. The security of your Duo application is tied to the security of your secret key. + ''; + example = "/run/keys/duo-skey"; + }; + + host = mkOption { + type = types.str; + description = "Duo API hostname."; + }; + + groups = mkOption { + type = types.str; + default = ""; + example = "users,!wheel,!*admin guests"; + description = '' + If specified, Duo authentication is required only for users + whose primary group or supplementary group list matches one + of the space-separated pattern lists. Refer to + <link xlink:href="https://duo.com/docs/duounix"/> for details. + ''; + }; + + failmode = mkOption { + type = types.enum [ "safe" "secure" ]; + default = "safe"; + description = '' + On service or configuration errors that prevent Duo + authentication, fail "safe" (allow access) or "secure" (deny + access). The default is "safe". + ''; + }; + + pushinfo = mkOption { + type = types.bool; + default = false; + description = '' + Include information such as the command to be executed in + the Duo Push message. + ''; + }; + + autopush = mkOption { + type = types.bool; + default = false; + description = '' + If <literal>true</literal>, Duo Unix will automatically send + a push login request to the user’s phone, falling back on a + phone call if push is unavailable. If + <literal>false</literal>, the user will be prompted to + choose an authentication method. When configured with + <literal>autopush = yes</literal>, we recommend setting + <literal>prompts = 1</literal>. + ''; + }; + + motd = mkOption { + type = types.bool; + default = false; + description = '' + Print the contents of <literal>/etc/motd</literal> to screen + after a successful login. + ''; + }; + + prompts = mkOption { + type = types.enum [ 1 2 3 ]; + default = 3; + description = '' + If a user fails to authenticate with a second factor, Duo + Unix will prompt the user to authenticate again. This option + sets the maximum number of prompts that Duo Unix will + display before denying access. Must be 1, 2, or 3. Default + is 3. + + For example, when <literal>prompts = 1</literal>, the user + will have to successfully authenticate on the first prompt, + whereas if <literal>prompts = 2</literal>, if the user + enters incorrect information at the initial prompt, he/she + will be prompted to authenticate again. + + When configured with <literal>autopush = true</literal>, we + recommend setting <literal>prompts = 1</literal>. + ''; + }; + + acceptEnvFactor = mkOption { + type = types.bool; + default = false; + description = '' + Look for factor selection or passcode in the + <literal>$DUO_PASSCODE</literal> environment variable before + prompting the user for input. + + When $DUO_PASSCODE is non-empty, it will override + autopush. The SSH client will need SendEnv DUO_PASSCODE in + its configuration, and the SSH server will similarly need + AcceptEnv DUO_PASSCODE. + ''; + }; + + fallbackLocalIP = mkOption { + type = types.bool; + default = false; + description = '' + Duo Unix reports the IP address of the authorizing user, for + the purposes of authorization and whitelisting. If Duo Unix + cannot detect the IP address of the client, setting + <literal>fallbackLocalIP = yes</literal> will cause Duo Unix + to send the IP address of the server it is running on. + + If you are using IP whitelisting, enabling this option could + cause unauthorized logins if the local IP is listed in the + whitelist. + ''; + }; + + allowTcpForwarding = mkOption { + type = types.bool; + default = false; + description = '' + By default, when SSH forwarding, enabling Duo Security will + disable TCP forwarding. By enabling this, you potentially + undermine some of the SSH based login security. Note this is + not needed if you use PAM. + ''; + }; + }; + }; + + config = mkIf (cfg.ssh.enable || cfg.pam.enable) { + environment.systemPackages = [ pkgs.duo-unix ]; + + security.wrappers.login_duo = + { setuid = true; + owner = "root"; + group = "root"; + source = "${pkgs.duo-unix.out}/bin/login_duo"; + }; + + system.activationScripts = { + login_duo = mkIf cfg.ssh.enable '' + if test -f "${cfg.secretKeyFile}"; then + mkdir -m 0755 -p /etc/duo + + umask 0077 + conf="$(mktemp)" + { + cat ${pkgs.writeText "login_duo.conf" configFileLogin} + printf 'skey = %s\n' "$(cat ${cfg.secretKeyFile})" + } >"$conf" + + chown sshd "$conf" + mv -fT "$conf" /etc/duo/login_duo.conf + fi + ''; + pam_duo = mkIf cfg.pam.enable '' + if test -f "${cfg.secretKeyFile}"; then + mkdir -m 0755 -p /etc/duo + + umask 0077 + conf="$(mktemp)" + { + cat ${pkgs.writeText "login_duo.conf" configFilePam} + printf 'skey = %s\n' "$(cat ${cfg.secretKeyFile})" + } >"$conf" + + mv -fT "$conf" /etc/duo/pam_duo.conf + fi + ''; + }; + + /* If PAM *and* SSH are enabled, then don't do anything special. + If PAM isn't used, set the default SSH-only options. */ + services.openssh.extraConfig = mkIf (cfg.ssh.enable || cfg.pam.enable) ( + if cfg.pam.enable then "UseDNS no" else '' + # Duo Security configuration + ForceCommand ${config.security.wrapperDir}/login_duo + PermitTunnel no + ${optionalString (!cfg.allowTcpForwarding) '' + AllowTcpForwarding no + ''} + ''); + }; +} diff --git a/nixos/modules/security/google_oslogin.nix b/nixos/modules/security/google_oslogin.nix new file mode 100644 index 00000000000..cf416035ef6 --- /dev/null +++ b/nixos/modules/security/google_oslogin.nix @@ -0,0 +1,71 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.security.googleOsLogin; + package = pkgs.google-guest-oslogin; + +in + +{ + + options = { + + security.googleOsLogin.enable = mkOption { + type = types.bool; + default = false; + description = '' + Whether to enable Google OS Login. + + The OS Login package enables the following components: + AuthorizedKeysCommand to query valid SSH keys from the user's OS Login + profile during ssh authentication phase. + NSS Module to provide user and group information + PAM Module for the sshd service, providing authorization and + authentication support, allowing the system to use data stored in + Google Cloud IAM permissions to control both, the ability to log into + an instance, and to perform operations as root (sudo). + ''; + }; + + }; + + config = mkIf cfg.enable { + security.pam.services.sshd = { + makeHomeDir = true; + googleOsLoginAccountVerification = true; + googleOsLoginAuthentication = true; + }; + + security.sudo.extraConfig = '' + #includedir /run/google-sudoers.d + ''; + systemd.tmpfiles.rules = [ + "d /run/google-sudoers.d 750 root root -" + "d /var/google-users.d 750 root root -" + ]; + + systemd.packages = [ package ]; + systemd.timers.google-oslogin-cache.wantedBy = [ "timers.target" ]; + + # enable the nss module, so user lookups etc. work + system.nssModules = [ package ]; + system.nssDatabases.passwd = [ "cache_oslogin" "oslogin" ]; + system.nssDatabases.group = [ "cache_oslogin" "oslogin" ]; + + # Ugly: sshd refuses to start if a store path is given because /nix/store is group-writable. + # So indirect by a symlink. + environment.etc."ssh/authorized_keys_command_google_oslogin" = { + mode = "0755"; + text = '' + #!/bin/sh + exec ${package}/bin/google_authorized_keys "$@" + ''; + }; + services.openssh.authorizedKeysCommand = "/etc/ssh/authorized_keys_command_google_oslogin %u"; + services.openssh.authorizedKeysCommandUser = "nobody"; + }; + +} diff --git a/nixos/modules/security/lock-kernel-modules.nix b/nixos/modules/security/lock-kernel-modules.nix new file mode 100644 index 00000000000..065587bc286 --- /dev/null +++ b/nixos/modules/security/lock-kernel-modules.nix @@ -0,0 +1,58 @@ +{ config, pkgs, lib, ... }: + +with lib; + +{ + meta = { + maintainers = [ maintainers.joachifm ]; + }; + + options = { + security.lockKernelModules = mkOption { + type = types.bool; + default = false; + description = '' + Disable kernel module loading once the system is fully initialised. + Module loading is disabled until the next reboot. Problems caused + by delayed module loading can be fixed by adding the module(s) in + question to <option>boot.kernelModules</option>. + ''; + }; + }; + + config = mkIf config.security.lockKernelModules { + boot.kernelModules = concatMap (x: + if x.device != null + then + if x.fsType == "vfat" + then [ "vfat" "nls-cp437" "nls-iso8859-1" ] + else [ x.fsType ] + else []) config.system.build.fileSystems; + + systemd.services.disable-kernel-module-loading = { + description = "Disable kernel module loading"; + + wants = [ "systemd-udevd.service" ]; + wantedBy = [ config.systemd.defaultUnit ]; + + after = + [ "firewall.service" + "systemd-modules-load.service" + config.systemd.defaultUnit + ]; + + unitConfig.ConditionPathIsReadWrite = "/proc/sys/kernel"; + + serviceConfig = + { Type = "oneshot"; + RemainAfterExit = true; + TimeoutSec = 180; + }; + + script = '' + ${pkgs.udev}/bin/udevadm settle + echo -n 1 >/proc/sys/kernel/modules_disabled + ''; + }; + }; +} diff --git a/nixos/modules/security/misc.nix b/nixos/modules/security/misc.nix new file mode 100644 index 00000000000..c20e067b8cc --- /dev/null +++ b/nixos/modules/security/misc.nix @@ -0,0 +1,155 @@ +{ config, lib, ... }: + +with lib; + +{ + meta = { + maintainers = [ maintainers.joachifm ]; + }; + + imports = [ + (lib.mkRenamedOptionModule [ "security" "virtualization" "flushL1DataCache" ] [ "security" "virtualisation" "flushL1DataCache" ]) + ]; + + options = { + security.allowUserNamespaces = mkOption { + type = types.bool; + default = true; + description = '' + Whether to allow creation of user namespaces. + + The motivation for disabling user namespaces is the potential + presence of code paths where the kernel's permission checking + logic fails to account for namespacing, instead permitting a + namespaced process to act outside the namespace with the same + privileges as it would have inside it. This is particularly + damaging in the common case of running as root within the namespace. + + When user namespace creation is disallowed, attempting to create a + user namespace fails with "no space left on device" (ENOSPC). + root may re-enable user namespace creation at runtime. + ''; + }; + + security.unprivilegedUsernsClone = mkOption { + type = types.bool; + default = false; + description = '' + When disabled, unprivileged users will not be able to create new namespaces. + By default unprivileged user namespaces are disabled. + This option only works in a hardened profile. + ''; + }; + + security.protectKernelImage = mkOption { + type = types.bool; + default = false; + description = '' + Whether to prevent replacing the running kernel image. + ''; + }; + + security.allowSimultaneousMultithreading = mkOption { + type = types.bool; + default = true; + description = '' + Whether to allow SMT/hyperthreading. Disabling SMT means that only + physical CPU cores will be usable at runtime, potentially at + significant performance cost. + + The primary motivation for disabling SMT is to mitigate the risk of + leaking data between threads running on the same CPU core (due to + e.g., shared caches). This attack vector is unproven. + + Disabling SMT is a supplement to the L1 data cache flushing mitigation + (see <xref linkend="opt-security.virtualisation.flushL1DataCache"/>) + versus malicious VM guests (SMT could "bring back" previously flushed + data). + ''; + }; + + security.forcePageTableIsolation = mkOption { + type = types.bool; + default = false; + description = '' + Whether to force-enable the Page Table Isolation (PTI) Linux kernel + feature even on CPU models that claim to be safe from Meltdown. + + This hardening feature is most beneficial to systems that run untrusted + workloads that rely on address space isolation for security. + ''; + }; + + security.virtualisation.flushL1DataCache = mkOption { + type = types.nullOr (types.enum [ "never" "cond" "always" ]); + default = null; + description = '' + Whether the hypervisor should flush the L1 data cache before + entering guests. + See also <xref linkend="opt-security.allowSimultaneousMultithreading"/>. + + <variablelist> + <varlistentry> + <term><literal>null</literal></term> + <listitem><para>uses the kernel default</para></listitem> + </varlistentry> + <varlistentry> + <term><literal>"never"</literal></term> + <listitem><para>disables L1 data cache flushing entirely. + May be appropriate if all guests are trusted.</para></listitem> + </varlistentry> + <varlistentry> + <term><literal>"cond"</literal></term> + <listitem><para>flushes L1 data cache only for pre-determined + code paths. May leak information about the host address space + layout.</para></listitem> + </varlistentry> + <varlistentry> + <term><literal>"always"</literal></term> + <listitem><para>flushes L1 data cache every time the hypervisor + enters the guest. May incur significant performance cost. + </para></listitem> + </varlistentry> + </variablelist> + ''; + }; + }; + + config = mkMerge [ + (mkIf (!config.security.allowUserNamespaces) { + # Setting the number of allowed user namespaces to 0 effectively disables + # the feature at runtime. Note that root may raise the limit again + # at any time. + boot.kernel.sysctl."user.max_user_namespaces" = 0; + + assertions = [ + { assertion = config.nix.settings.sandbox -> config.security.allowUserNamespaces; + message = "`nix.settings.sandbox = true` conflicts with `!security.allowUserNamespaces`."; + } + ]; + }) + + (mkIf config.security.unprivilegedUsernsClone { + boot.kernel.sysctl."kernel.unprivileged_userns_clone" = mkDefault true; + }) + + (mkIf config.security.protectKernelImage { + # Disable hibernation (allows replacing the running kernel) + boot.kernelParams = [ "nohibernate" ]; + # Prevent replacing the running kernel image w/o reboot + boot.kernel.sysctl."kernel.kexec_load_disabled" = mkDefault true; + }) + + (mkIf (!config.security.allowSimultaneousMultithreading) { + boot.kernelParams = [ "nosmt" ]; + }) + + (mkIf config.security.forcePageTableIsolation { + boot.kernelParams = [ "pti=on" ]; + }) + + (mkIf (config.security.virtualisation.flushL1DataCache != null) { + boot.kernelParams = [ "kvm-intel.vmentry_l1d_flush=${config.security.virtualisation.flushL1DataCache}" ]; + }) + ]; +} diff --git a/nixos/modules/security/oath.nix b/nixos/modules/security/oath.nix new file mode 100644 index 00000000000..93bdc851117 --- /dev/null +++ b/nixos/modules/security/oath.nix @@ -0,0 +1,50 @@ +# This module provides configuration for the OATH PAM modules. + +{ lib, ... }: + +with lib; + +{ + options = { + + security.pam.oath = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Enable the OATH (one-time password) PAM module. + ''; + }; + + digits = mkOption { + type = types.enum [ 6 7 8 ]; + default = 6; + description = '' + Specify the length of the one-time password in number of + digits. + ''; + }; + + window = mkOption { + type = types.int; + default = 5; + description = '' + Specify the number of one-time passwords to check in order + to accommodate for situations where the system and the + client are slightly out of sync (iteration for HOTP or time + steps for TOTP). + ''; + }; + + usersFile = mkOption { + type = types.path; + default = "/etc/users.oath"; + description = '' + Set the path to file where the user's credentials are + stored. This file must not be world readable! + ''; + }; + }; + + }; +} diff --git a/nixos/modules/security/pam.nix b/nixos/modules/security/pam.nix new file mode 100644 index 00000000000..c0ef8b5f30b --- /dev/null +++ b/nixos/modules/security/pam.nix @@ -0,0 +1,1149 @@ +# This module provides configuration for the PAM (Pluggable +# Authentication Modules) system. + +{ config, lib, pkgs, ... }: + +with lib; + +let + parentConfig = config; + + pamOpts = { config, name, ... }: let cfg = config; in let config = parentConfig; in { + + options = { + + name = mkOption { + example = "sshd"; + type = types.str; + description = "Name of the PAM service."; + }; + + unixAuth = mkOption { + default = true; + type = types.bool; + description = '' + Whether users can log in with passwords defined in + <filename>/etc/shadow</filename>. + ''; + }; + + rootOK = mkOption { + default = false; + type = types.bool; + description = '' + If set, root doesn't need to authenticate (e.g. for the + <command>useradd</command> service). + ''; + }; + + p11Auth = mkOption { + default = config.security.pam.p11.enable; + defaultText = literalExpression "config.security.pam.p11.enable"; + type = types.bool; + description = '' + If set, keys listed in + <filename>~/.ssh/authorized_keys</filename> and + <filename>~/.eid/authorized_certificates</filename> + can be used to log in with the associated PKCS#11 tokens. + ''; + }; + + u2fAuth = mkOption { + default = config.security.pam.u2f.enable; + defaultText = literalExpression "config.security.pam.u2f.enable"; + type = types.bool; + description = '' + If set, users listed in + <filename>$XDG_CONFIG_HOME/Yubico/u2f_keys</filename> (or + <filename>$HOME/.config/Yubico/u2f_keys</filename> if XDG variable is + not set) are able to log in with the associated U2F key. Path can be + changed using <option>security.pam.u2f.authFile</option> option. + ''; + }; + + yubicoAuth = mkOption { + default = config.security.pam.yubico.enable; + defaultText = literalExpression "config.security.pam.yubico.enable"; + type = types.bool; + description = '' + If set, users listed in + <filename>~/.yubico/authorized_yubikeys</filename> + are able to log in with the associated Yubikey tokens. + ''; + }; + + googleAuthenticator = { + enable = mkOption { + default = false; + type = types.bool; + description = '' + If set, users with enabled Google Authenticator (created + <filename>~/.google_authenticator</filename>) will be required + to provide Google Authenticator token to log in. + ''; + }; + }; + + usbAuth = mkOption { + default = config.security.pam.usb.enable; + defaultText = literalExpression "config.security.pam.usb.enable"; + type = types.bool; + description = '' + If set, users listed in + <filename>/etc/pamusb.conf</filename> are able to log in + with the associated USB key. + ''; + }; + + otpwAuth = mkOption { + default = config.security.pam.enableOTPW; + defaultText = literalExpression "config.security.pam.enableOTPW"; + type = types.bool; + description = '' + If set, the OTPW system will be used (if + <filename>~/.otpw</filename> exists). + ''; + }; + + googleOsLoginAccountVerification = mkOption { + default = false; + type = types.bool; + description = '' + If set, will use the Google OS Login PAM modules + (<literal>pam_oslogin_login</literal>, + <literal>pam_oslogin_admin</literal>) to verify possible OS Login + users and set sudoers configuration accordingly. + This only makes sense to enable for the <literal>sshd</literal> PAM + service. + ''; + }; + + googleOsLoginAuthentication = mkOption { + default = false; + type = types.bool; + description = '' + If set, will use the <literal>pam_oslogin_login</literal>'s user + authentication methods to authenticate users using 2FA. + This only makes sense to enable for the <literal>sshd</literal> PAM + service. + ''; + }; + + fprintAuth = mkOption { + default = config.services.fprintd.enable; + defaultText = literalExpression "config.services.fprintd.enable"; + type = types.bool; + description = '' + If set, fingerprint reader will be used (if exists and + your fingerprints are enrolled). + ''; + }; + + oathAuth = mkOption { + default = config.security.pam.oath.enable; + defaultText = literalExpression "config.security.pam.oath.enable"; + type = types.bool; + description = '' + If set, the OATH Toolkit will be used. + ''; + }; + + sshAgentAuth = mkOption { + default = false; + type = types.bool; + description = '' + If set, the calling user's SSH agent is used to authenticate + against the keys in the calling user's + <filename>~/.ssh/authorized_keys</filename>. This is useful + for <command>sudo</command> on password-less remote systems. + ''; + }; + + duoSecurity = { + enable = mkOption { + default = false; + type = types.bool; + description = '' + If set, use the Duo Security pam module + <literal>pam_duo</literal> for authentication. Requires + configuration of <option>security.duosec</option> options. + ''; + }; + }; + + startSession = mkOption { + default = false; + type = types.bool; + description = '' + If set, the service will register a new session with + systemd's login manager. For local sessions, this will give + the user access to audio devices, CD-ROM drives. In the + default PolicyKit configuration, it also allows the user to + reboot the system. + ''; + }; + + setEnvironment = mkOption { + type = types.bool; + default = true; + description = '' + Whether the service should set the environment variables + listed in <option>environment.sessionVariables</option> + using <literal>pam_env.so</literal>. + ''; + }; + + setLoginUid = mkOption { + type = types.bool; + description = '' + Set the login uid of the process + (<filename>/proc/self/loginuid</filename>) for auditing + purposes. The login uid is only set by ‘entry points’ like + <command>login</command> and <command>sshd</command>, not by + commands like <command>sudo</command>. + ''; + }; + + ttyAudit = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Enable or disable TTY auditing for specified users + ''; + }; + + enablePattern = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + For each user matching one of comma-separated + glob patterns, enable TTY auditing + ''; + }; + + disablePattern = mkOption { + type = types.nullOr types.str; + default = null; + description = '' + For each user matching one of comma-separated + glob patterns, disable TTY auditing + ''; + }; + + openOnly = mkOption { + type = types.bool; + default = false; + description = '' + Set the TTY audit flag when opening the session, + but do not restore it when closing the session. + Using this option is necessary for some services + that don't fork() to run the authenticated session, + such as sudo. + ''; + }; + }; + + forwardXAuth = mkOption { + default = false; + type = types.bool; + description = '' + Whether X authentication keys should be passed from the + calling user to the target user (e.g. for + <command>su</command>) + ''; + }; + + pamMount = mkOption { + default = config.security.pam.mount.enable; + defaultText = literalExpression "config.security.pam.mount.enable"; + type = types.bool; + description = '' + Enable PAM mount (pam_mount) system to mount fileystems on user login. + ''; + }; + + allowNullPassword = mkOption { + default = false; + type = types.bool; + description = '' + Whether to allow logging into accounts that have no password + set (i.e., have an empty password field in + <filename>/etc/passwd</filename> or + <filename>/etc/group</filename>). This does not enable + logging into disabled accounts (i.e., that have the password + field set to <literal>!</literal>). Note that regardless of + what the pam_unix documentation says, accounts with hashed + empty passwords are always allowed to log in. + ''; + }; + + nodelay = mkOption { + default = false; + type = types.bool; + description = '' + Wheather the delay after typing a wrong password should be disabled. + ''; + }; + + requireWheel = mkOption { + default = false; + type = types.bool; + description = '' + Whether to permit root access only to members of group wheel. + ''; + }; + + limits = mkOption { + default = []; + type = limitsType; + description = '' + Attribute set describing resource limits. Defaults to the + value of <option>security.pam.loginLimits</option>. + The meaning of the values is explained in <citerefentry> + <refentrytitle>limits.conf</refentrytitle><manvolnum>5</manvolnum> + </citerefentry>. + ''; + }; + + showMotd = mkOption { + default = false; + type = types.bool; + description = "Whether to show the message of the day."; + }; + + makeHomeDir = mkOption { + default = false; + type = types.bool; + description = '' + Whether to try to create home directories for users + with <literal>$HOME</literal>s pointing to nonexistent + locations on session login. + ''; + }; + + updateWtmp = mkOption { + default = false; + type = types.bool; + description = "Whether to update <filename>/var/log/wtmp</filename>."; + }; + + logFailures = mkOption { + default = false; + type = types.bool; + description = "Whether to log authentication failures in <filename>/var/log/faillog</filename>."; + }; + + enableAppArmor = mkOption { + default = false; + type = types.bool; + description = '' + Enable support for attaching AppArmor profiles at the + user/group level, e.g., as part of a role based access + control scheme. + ''; + }; + + enableKwallet = mkOption { + default = false; + type = types.bool; + description = '' + If enabled, pam_wallet will attempt to automatically unlock the + user's default KDE wallet upon login. If the user has no wallet named + "kdewallet", or the login password does not match their wallet + password, KDE will prompt separately after login. + ''; + }; + sssdStrictAccess = mkOption { + default = false; + type = types.bool; + description = "enforce sssd access control"; + }; + + enableGnomeKeyring = mkOption { + default = false; + type = types.bool; + description = '' + If enabled, pam_gnome_keyring will attempt to automatically unlock the + user's default Gnome keyring upon login. If the user login password does + not match their keyring password, Gnome Keyring will prompt separately + after login. + ''; + }; + + gnupg = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + If enabled, pam_gnupg will attempt to automatically unlock the + user's GPG keys with the login password via + <command>gpg-agent</command>. The keygrips of all keys to be + unlocked should be written to <filename>~/.pam-gnupg</filename>, + and can be queried with <command>gpg -K --with-keygrip</command>. + Presetting passphrases must be enabled by adding + <literal>allow-preset-passphrase</literal> in + <filename>~/.gnupg/gpg-agent.conf</filename>. + ''; + }; + + noAutostart = mkOption { + type = types.bool; + default = false; + description = '' + Don't start <command>gpg-agent</command> if it is not running. + Useful in conjunction with starting <command>gpg-agent</command> as + a systemd user service. + ''; + }; + + storeOnly = mkOption { + type = types.bool; + default = false; + description = '' + Don't send the password immediately after login, but store for PAM + <literal>session</literal>. + ''; + }; + }; + + text = mkOption { + type = types.nullOr types.lines; + description = "Contents of the PAM service file."; + }; + + }; + + # The resulting /etc/pam.d/* file contents are verified in + # nixos/tests/pam/pam-file-contents.nix. Please update tests there when + # changing the derivation. + config = { + name = mkDefault name; + setLoginUid = mkDefault cfg.startSession; + limits = mkDefault config.security.pam.loginLimits; + + # !!! TODO: move the LDAP stuff to the LDAP module, and the + # Samba stuff to the Samba module. This requires that the PAM + # module provides the right hooks. + text = mkDefault + ( + '' + # Account management. + account required pam_unix.so + '' + + optionalString use_ldap '' + account sufficient ${pam_ldap}/lib/security/pam_ldap.so + '' + + optionalString (config.services.sssd.enable && cfg.sssdStrictAccess==false) '' + account sufficient ${pkgs.sssd}/lib/security/pam_sss.so + '' + + optionalString (config.services.sssd.enable && cfg.sssdStrictAccess) '' + account [default=bad success=ok user_unknown=ignore] ${pkgs.sssd}/lib/security/pam_sss.so + '' + + optionalString config.krb5.enable '' + account sufficient ${pam_krb5}/lib/security/pam_krb5.so + '' + + optionalString cfg.googleOsLoginAccountVerification '' + account [success=ok ignore=ignore default=die] ${pkgs.google-guest-oslogin}/lib/security/pam_oslogin_login.so + account [success=ok default=ignore] ${pkgs.google-guest-oslogin}/lib/security/pam_oslogin_admin.so + '' + + '' + + # Authentication management. + '' + + optionalString cfg.googleOsLoginAuthentication '' + auth [success=done perm_denied=die default=ignore] ${pkgs.google-guest-oslogin}/lib/security/pam_oslogin_login.so + '' + + optionalString cfg.rootOK '' + auth sufficient pam_rootok.so + '' + + optionalString cfg.requireWheel '' + auth required pam_wheel.so use_uid + '' + + optionalString cfg.logFailures '' + auth required pam_faillock.so + '' + + optionalString (config.security.pam.enableSSHAgentAuth && cfg.sshAgentAuth) '' + auth sufficient ${pkgs.pam_ssh_agent_auth}/libexec/pam_ssh_agent_auth.so file=${lib.concatStringsSep ":" config.services.openssh.authorizedKeysFiles} + '' + + (let p11 = config.security.pam.p11; in optionalString cfg.p11Auth '' + auth ${p11.control} ${pkgs.pam_p11}/lib/security/pam_p11.so ${pkgs.opensc}/lib/opensc-pkcs11.so + '') + + (let u2f = config.security.pam.u2f; in optionalString cfg.u2fAuth '' + auth ${u2f.control} ${pkgs.pam_u2f}/lib/security/pam_u2f.so ${optionalString u2f.debug "debug"} ${optionalString (u2f.authFile != null) "authfile=${u2f.authFile}"} ${optionalString u2f.interactive "interactive"} ${optionalString u2f.cue "cue"} ${optionalString (u2f.appId != null) "appid=${u2f.appId}"} + '') + + optionalString cfg.usbAuth '' + auth sufficient ${pkgs.pam_usb}/lib/security/pam_usb.so + '' + + (let oath = config.security.pam.oath; in optionalString cfg.oathAuth '' + auth requisite ${pkgs.oathToolkit}/lib/security/pam_oath.so window=${toString oath.window} usersfile=${toString oath.usersFile} digits=${toString oath.digits} + '') + + (let yubi = config.security.pam.yubico; in optionalString cfg.yubicoAuth '' + auth ${yubi.control} ${pkgs.yubico-pam}/lib/security/pam_yubico.so mode=${toString yubi.mode} ${optionalString (yubi.challengeResponsePath != null) "chalresp_path=${yubi.challengeResponsePath}"} ${optionalString (yubi.mode == "client") "id=${toString yubi.id}"} ${optionalString yubi.debug "debug"} + '') + + optionalString cfg.fprintAuth '' + auth sufficient ${pkgs.fprintd}/lib/security/pam_fprintd.so + '' + + # Modules in this block require having the password set in PAM_AUTHTOK. + # pam_unix is marked as 'sufficient' on NixOS which means nothing will run + # after it succeeds. Certain modules need to run after pam_unix + # prompts the user for password so we run it once with 'required' at an + # earlier point and it will run again with 'sufficient' further down. + # We use try_first_pass the second time to avoid prompting password twice + (optionalString (cfg.unixAuth && + (config.security.pam.enableEcryptfs + || cfg.pamMount + || cfg.enableKwallet + || cfg.enableGnomeKeyring + || cfg.googleAuthenticator.enable + || cfg.gnupg.enable + || cfg.duoSecurity.enable)) + ( + '' + auth required pam_unix.so ${optionalString cfg.allowNullPassword "nullok"} ${optionalString cfg.nodelay "nodelay"} likeauth + '' + + optionalString config.security.pam.enableEcryptfs '' + auth optional ${pkgs.ecryptfs}/lib/security/pam_ecryptfs.so unwrap + '' + + optionalString cfg.pamMount '' + auth optional ${pkgs.pam_mount}/lib/security/pam_mount.so disable_interactive + '' + + optionalString cfg.enableKwallet '' + auth optional ${pkgs.plasma5Packages.kwallet-pam}/lib/security/pam_kwallet5.so kwalletd=${pkgs.plasma5Packages.kwallet.bin}/bin/kwalletd5 + '' + + optionalString cfg.enableGnomeKeyring '' + auth optional ${pkgs.gnome.gnome-keyring}/lib/security/pam_gnome_keyring.so + '' + + optionalString cfg.gnupg.enable '' + auth optional ${pkgs.pam_gnupg}/lib/security/pam_gnupg.so ${optionalString cfg.gnupg.storeOnly " store-only"} + '' + + optionalString cfg.googleAuthenticator.enable '' + auth required ${pkgs.google-authenticator}/lib/security/pam_google_authenticator.so no_increment_hotp + '' + + optionalString cfg.duoSecurity.enable '' + auth required ${pkgs.duo-unix}/lib/security/pam_duo.so + '' + )) + + optionalString cfg.unixAuth '' + auth sufficient pam_unix.so ${optionalString cfg.allowNullPassword "nullok"} ${optionalString cfg.nodelay "nodelay"} likeauth try_first_pass + '' + + optionalString cfg.otpwAuth '' + auth sufficient ${pkgs.otpw}/lib/security/pam_otpw.so + '' + + optionalString use_ldap '' + auth sufficient ${pam_ldap}/lib/security/pam_ldap.so use_first_pass + '' + + optionalString config.services.sssd.enable '' + auth sufficient ${pkgs.sssd}/lib/security/pam_sss.so use_first_pass + '' + + optionalString config.krb5.enable '' + auth [default=ignore success=1 service_err=reset] ${pam_krb5}/lib/security/pam_krb5.so use_first_pass + auth [default=die success=done] ${pam_ccreds}/lib/security/pam_ccreds.so action=validate use_first_pass + auth sufficient ${pam_ccreds}/lib/security/pam_ccreds.so action=store use_first_pass + '' + + '' + auth required pam_deny.so + + # Password management. + password sufficient pam_unix.so nullok sha512 + '' + + optionalString config.security.pam.enableEcryptfs '' + password optional ${pkgs.ecryptfs}/lib/security/pam_ecryptfs.so + '' + + optionalString cfg.pamMount '' + password optional ${pkgs.pam_mount}/lib/security/pam_mount.so + '' + + optionalString use_ldap '' + password sufficient ${pam_ldap}/lib/security/pam_ldap.so + '' + + optionalString config.services.sssd.enable '' + password sufficient ${pkgs.sssd}/lib/security/pam_sss.so use_authtok + '' + + optionalString config.krb5.enable '' + password sufficient ${pam_krb5}/lib/security/pam_krb5.so use_first_pass + '' + + optionalString cfg.enableGnomeKeyring '' + password optional ${pkgs.gnome.gnome-keyring}/lib/security/pam_gnome_keyring.so use_authtok + '' + + '' + + # Session management. + '' + + optionalString cfg.setEnvironment '' + session required pam_env.so conffile=/etc/pam/environment readenv=0 + '' + + '' + session required pam_unix.so + '' + + optionalString cfg.setLoginUid '' + session ${if config.boot.isContainer then "optional" else "required"} pam_loginuid.so + '' + + optionalString cfg.ttyAudit.enable '' + session required ${pkgs.pam}/lib/security/pam_tty_audit.so + open_only=${toString cfg.ttyAudit.openOnly} + ${optionalString (cfg.ttyAudit.enablePattern != null) "enable=${cfg.ttyAudit.enablePattern}"} + ${optionalString (cfg.ttyAudit.disablePattern != null) "disable=${cfg.ttyAudit.disablePattern}"} + '' + + optionalString cfg.makeHomeDir '' + session required ${pkgs.pam}/lib/security/pam_mkhomedir.so silent skel=${config.security.pam.makeHomeDir.skelDirectory} umask=0077 + '' + + optionalString cfg.updateWtmp '' + session required ${pkgs.pam}/lib/security/pam_lastlog.so silent + '' + + optionalString config.security.pam.enableEcryptfs '' + session optional ${pkgs.ecryptfs}/lib/security/pam_ecryptfs.so + '' + + optionalString cfg.pamMount '' + session optional ${pkgs.pam_mount}/lib/security/pam_mount.so disable_interactive + '' + + optionalString use_ldap '' + session optional ${pam_ldap}/lib/security/pam_ldap.so + '' + + optionalString config.services.sssd.enable '' + session optional ${pkgs.sssd}/lib/security/pam_sss.so + '' + + optionalString config.krb5.enable '' + session optional ${pam_krb5}/lib/security/pam_krb5.so + '' + + optionalString cfg.otpwAuth '' + session optional ${pkgs.otpw}/lib/security/pam_otpw.so + '' + + optionalString cfg.startSession '' + session optional ${pkgs.systemd}/lib/security/pam_systemd.so + '' + + optionalString cfg.forwardXAuth '' + session optional pam_xauth.so xauthpath=${pkgs.xorg.xauth}/bin/xauth systemuser=99 + '' + + optionalString (cfg.limits != []) '' + session required ${pkgs.pam}/lib/security/pam_limits.so conf=${makeLimitsConf cfg.limits} + '' + + optionalString (cfg.showMotd && config.users.motd != null) '' + session optional ${pkgs.pam}/lib/security/pam_motd.so motd=${motd} + '' + + optionalString (cfg.enableAppArmor && config.security.apparmor.enable) '' + session optional ${pkgs.apparmor-pam}/lib/security/pam_apparmor.so order=user,group,default debug + '' + + optionalString (cfg.enableKwallet) '' + session optional ${pkgs.plasma5Packages.kwallet-pam}/lib/security/pam_kwallet5.so kwalletd=${pkgs.plasma5Packages.kwallet.bin}/bin/kwalletd5 + '' + + optionalString (cfg.enableGnomeKeyring) '' + session optional ${pkgs.gnome.gnome-keyring}/lib/security/pam_gnome_keyring.so auto_start + '' + + optionalString cfg.gnupg.enable '' + session optional ${pkgs.pam_gnupg}/lib/security/pam_gnupg.so ${optionalString cfg.gnupg.noAutostart " no-autostart"} + '' + + optionalString (config.virtualisation.lxc.lxcfs.enable) '' + session optional ${pkgs.lxc}/lib/security/pam_cgfs.so -c all + '' + ); + }; + + }; + + + inherit (pkgs) pam_krb5 pam_ccreds; + + use_ldap = (config.users.ldap.enable && config.users.ldap.loginPam); + pam_ldap = if config.users.ldap.daemon.enable then pkgs.nss_pam_ldapd else pkgs.pam_ldap; + + # Create a limits.conf(5) file. + makeLimitsConf = limits: + pkgs.writeText "limits.conf" + (concatMapStrings ({ domain, type, item, value }: + "${domain} ${type} ${item} ${toString value}\n") + limits); + + limitsType = with lib.types; listOf (submodule ({ ... }: { + options = { + domain = mkOption { + description = "Username, groupname, or wildcard this limit applies to"; + example = "@wheel"; + type = str; + }; + + type = mkOption { + description = "Type of this limit"; + type = enum [ "-" "hard" "soft" ]; + default = "-"; + }; + + item = mkOption { + description = "Item this limit applies to"; + type = enum [ + "core" + "data" + "fsize" + "memlock" + "nofile" + "rss" + "stack" + "cpu" + "nproc" + "as" + "maxlogins" + "maxsyslogins" + "priority" + "locks" + "sigpending" + "msgqueue" + "nice" + "rtprio" + ]; + }; + + value = mkOption { + description = "Value of this limit"; + type = oneOf [ str int ]; + }; + }; + })); + + motd = pkgs.writeText "motd" config.users.motd; + + makePAMService = name: service: + { name = "pam.d/${name}"; + value.source = pkgs.writeText "${name}.pam" service.text; + }; + +in + +{ + + imports = [ + (mkRenamedOptionModule [ "security" "pam" "enableU2F" ] [ "security" "pam" "u2f" "enable" ]) + ]; + + ###### interface + + options = { + + security.pam.loginLimits = mkOption { + default = []; + type = limitsType; + example = + [ { domain = "ftp"; + type = "hard"; + item = "nproc"; + value = "0"; + } + { domain = "@student"; + type = "-"; + item = "maxlogins"; + value = "4"; + } + ]; + + description = + '' Define resource limits that should apply to users or groups. + Each item in the list should be an attribute set with a + <varname>domain</varname>, <varname>type</varname>, + <varname>item</varname>, and <varname>value</varname> + attribute. The syntax and semantics of these attributes + must be that described in <citerefentry><refentrytitle>limits.conf</refentrytitle> + <manvolnum>5</manvolnum></citerefentry>. + + Note that these limits do not apply to systemd services, + whose limits can be changed via <option>systemd.extraConfig</option> + instead. + ''; + }; + + security.pam.services = mkOption { + default = {}; + type = with types; attrsOf (submodule pamOpts); + description = + '' + This option defines the PAM services. A service typically + corresponds to a program that uses PAM, + e.g. <command>login</command> or <command>passwd</command>. + Each attribute of this set defines a PAM service, with the attribute name + defining the name of the service. + ''; + }; + + security.pam.makeHomeDir.skelDirectory = mkOption { + type = types.str; + default = "/var/empty"; + example = "/etc/skel"; + description = '' + Path to skeleton directory whose contents are copied to home + directories newly created by <literal>pam_mkhomedir</literal>. + ''; + }; + + security.pam.enableSSHAgentAuth = mkOption { + type = types.bool; + default = false; + description = + '' + Enable sudo logins if the user's SSH agent provides a key + present in <filename>~/.ssh/authorized_keys</filename>. + This allows machines to exclusively use SSH keys instead of + passwords. + ''; + }; + + security.pam.enableOTPW = mkEnableOption "the OTPW (one-time password) PAM module"; + + security.pam.p11 = { + enable = mkOption { + default = false; + type = types.bool; + description = '' + Enables P11 PAM (<literal>pam_p11</literal>) module. + + If set, users can log in with SSH keys and PKCS#11 tokens. + + More information can be found <link + xlink:href="https://github.com/OpenSC/pam_p11">here</link>. + ''; + }; + + control = mkOption { + default = "sufficient"; + type = types.enum [ "required" "requisite" "sufficient" "optional" ]; + description = '' + This option sets pam "control". + If you want to have multi factor authentication, use "required". + If you want to use the PKCS#11 device instead of the regular password, + use "sufficient". + + Read + <citerefentry> + <refentrytitle>pam.conf</refentrytitle> + <manvolnum>5</manvolnum> + </citerefentry> + for better understanding of this option. + ''; + }; + }; + + security.pam.u2f = { + enable = mkOption { + default = false; + type = types.bool; + description = '' + Enables U2F PAM (<literal>pam-u2f</literal>) module. + + If set, users listed in + <filename>$XDG_CONFIG_HOME/Yubico/u2f_keys</filename> (or + <filename>$HOME/.config/Yubico/u2f_keys</filename> if XDG variable is + not set) are able to log in with the associated U2F key. The path can + be changed using <option>security.pam.u2f.authFile</option> option. + + File format is: + <literal>username:first_keyHandle,first_public_key: second_keyHandle,second_public_key</literal> + This file can be generated using <command>pamu2fcfg</command> command. + + More information can be found <link + xlink:href="https://developers.yubico.com/pam-u2f/">here</link>. + ''; + }; + + authFile = mkOption { + default = null; + type = with types; nullOr path; + description = '' + By default <literal>pam-u2f</literal> module reads the keys from + <filename>$XDG_CONFIG_HOME/Yubico/u2f_keys</filename> (or + <filename>$HOME/.config/Yubico/u2f_keys</filename> if XDG variable is + not set). + + If you want to change auth file locations or centralize database (for + example use <filename>/etc/u2f-mappings</filename>) you can set this + option. + + File format is: + <literal>username:first_keyHandle,first_public_key: second_keyHandle,second_public_key</literal> + This file can be generated using <command>pamu2fcfg</command> command. + + More information can be found <link + xlink:href="https://developers.yubico.com/pam-u2f/">here</link>. + ''; + }; + + appId = mkOption { + default = null; + type = with types; nullOr str; + description = '' + By default <literal>pam-u2f</literal> module sets the application + ID to <literal>pam://$HOSTNAME</literal>. + + When using <command>pamu2fcfg</command>, you can specify your + application ID with the <literal>-i</literal> flag. + + More information can be found <link + xlink:href="https://developers.yubico.com/pam-u2f/Manuals/pam_u2f.8.html"> + here</link> + ''; + }; + + control = mkOption { + default = "sufficient"; + type = types.enum [ "required" "requisite" "sufficient" "optional" ]; + description = '' + This option sets pam "control". + If you want to have multi factor authentication, use "required". + If you want to use U2F device instead of regular password, use "sufficient". + + Read + <citerefentry> + <refentrytitle>pam.conf</refentrytitle> + <manvolnum>5</manvolnum> + </citerefentry> + for better understanding of this option. + ''; + }; + + debug = mkOption { + default = false; + type = types.bool; + description = '' + Debug output to stderr. + ''; + }; + + interactive = mkOption { + default = false; + type = types.bool; + description = '' + Set to prompt a message and wait before testing the presence of a U2F device. + Recommended if your device doesn’t have a tactile trigger. + ''; + }; + + cue = mkOption { + default = false; + type = types.bool; + description = '' + By default <literal>pam-u2f</literal> module does not inform user + that he needs to use the u2f device, it just waits without a prompt. + + If you set this option to <literal>true</literal>, + <literal>cue</literal> option is added to <literal>pam-u2f</literal> + module and reminder message will be displayed. + ''; + }; + }; + + security.pam.yubico = { + enable = mkOption { + default = false; + type = types.bool; + description = '' + Enables Yubico PAM (<literal>yubico-pam</literal>) module. + + If set, users listed in + <filename>~/.yubico/authorized_yubikeys</filename> + are able to log in with the associated Yubikey tokens. + + The file must have only one line: + <literal>username:yubikey_token_id1:yubikey_token_id2</literal> + More information can be found <link + xlink:href="https://developers.yubico.com/yubico-pam/">here</link>. + ''; + }; + control = mkOption { + default = "sufficient"; + type = types.enum [ "required" "requisite" "sufficient" "optional" ]; + description = '' + This option sets pam "control". + If you want to have multi factor authentication, use "required". + If you want to use Yubikey instead of regular password, use "sufficient". + + Read + <citerefentry> + <refentrytitle>pam.conf</refentrytitle> + <manvolnum>5</manvolnum> + </citerefentry> + for better understanding of this option. + ''; + }; + id = mkOption { + example = "42"; + type = types.str; + description = "client id"; + }; + + debug = mkOption { + default = false; + type = types.bool; + description = '' + Debug output to stderr. + ''; + }; + mode = mkOption { + default = "client"; + type = types.enum [ "client" "challenge-response" ]; + description = '' + Mode of operation. + + Use "client" for online validation with a YubiKey validation service such as + the YubiCloud. + + Use "challenge-response" for offline validation using YubiKeys with HMAC-SHA-1 + Challenge-Response configurations. See the man-page ykpamcfg(1) for further + details on how to configure offline Challenge-Response validation. + + More information can be found <link + xlink:href="https://developers.yubico.com/yubico-pam/Authentication_Using_Challenge-Response.html">here</link>. + ''; + }; + challengeResponsePath = mkOption { + default = null; + type = types.nullOr types.path; + description = '' + If not null, set the path used by yubico pam module where the challenge expected response is stored. + + More information can be found <link + xlink:href="https://developers.yubico.com/yubico-pam/Authentication_Using_Challenge-Response.html">here</link>. + ''; + }; + }; + + security.pam.enableEcryptfs = mkEnableOption "eCryptfs PAM module (mounting ecryptfs home directory on login)"; + + users.motd = mkOption { + default = null; + example = "Today is Sweetmorn, the 4th day of The Aftermath in the YOLD 3178."; + type = types.nullOr types.lines; + description = "Message of the day shown to users when they log in."; + }; + + }; + + + ###### implementation + + config = { + + environment.systemPackages = + # Include the PAM modules in the system path mostly for the manpages. + [ pkgs.pam ] + ++ optional config.users.ldap.enable pam_ldap + ++ optional config.services.sssd.enable pkgs.sssd + ++ optionals config.krb5.enable [pam_krb5 pam_ccreds] + ++ optionals config.security.pam.enableOTPW [ pkgs.otpw ] + ++ optionals config.security.pam.oath.enable [ pkgs.oathToolkit ] + ++ optionals config.security.pam.p11.enable [ pkgs.pam_p11 ] + ++ optionals config.security.pam.u2f.enable [ pkgs.pam_u2f ]; + + boot.supportedFilesystems = optionals config.security.pam.enableEcryptfs [ "ecryptfs" ]; + + security.wrappers = { + unix_chkpwd = { + setuid = true; + owner = "root"; + group = "root"; + source = "${pkgs.pam}/bin/unix_chkpwd"; + }; + }; + + environment.etc = mapAttrs' makePAMService config.security.pam.services; + + security.pam.services = + { other.text = + '' + auth required pam_warn.so + auth required pam_deny.so + account required pam_warn.so + account required pam_deny.so + password required pam_warn.so + password required pam_deny.so + session required pam_warn.so + session required pam_deny.so + ''; + + # Most of these should be moved to specific modules. + i3lock = {}; + i3lock-color = {}; + vlock = {}; + xlock = {}; + xscreensaver = {}; + + runuser = { rootOK = true; unixAuth = false; setEnvironment = false; }; + + /* FIXME: should runuser -l start a systemd session? Currently + it complains "Cannot create session: Already running in a + session". */ + runuser-l = { rootOK = true; unixAuth = false; }; + }; + + security.apparmor.includes."abstractions/pam" = let + isEnabled = test: fold or false (map test (attrValues config.security.pam.services)); + in + lib.concatMapStrings + (name: "r ${config.environment.etc."pam.d/${name}".source},\n") + (attrNames config.security.pam.services) + + '' + mr ${getLib pkgs.pam}/lib/security/pam_filter/*, + mr ${getLib pkgs.pam}/lib/security/pam_*.so, + r ${getLib pkgs.pam}/lib/security/, + '' + + optionalString use_ldap '' + mr ${pam_ldap}/lib/security/pam_ldap.so, + '' + + optionalString config.services.sssd.enable '' + mr ${pkgs.sssd}/lib/security/pam_sss.so, + '' + + optionalString config.krb5.enable '' + mr ${pam_krb5}/lib/security/pam_krb5.so, + mr ${pam_ccreds}/lib/security/pam_ccreds.so, + '' + + optionalString (isEnabled (cfg: cfg.googleOsLoginAccountVerification)) '' + mr ${pkgs.google-guest-oslogin}/lib/security/pam_oslogin_login.so, + mr ${pkgs.google-guest-oslogin}/lib/security/pam_oslogin_admin.so, + '' + + optionalString (isEnabled (cfg: cfg.googleOsLoginAuthentication)) '' + mr ${pkgs.google-guest-oslogin}/lib/security/pam_oslogin_login.so, + '' + + optionalString (config.security.pam.enableSSHAgentAuth + && isEnabled (cfg: cfg.sshAgentAuth)) '' + mr ${pkgs.pam_ssh_agent_auth}/libexec/pam_ssh_agent_auth.so, + '' + + optionalString (isEnabled (cfg: cfg.fprintAuth)) '' + mr ${pkgs.fprintd}/lib/security/pam_fprintd.so, + '' + + optionalString (isEnabled (cfg: cfg.u2fAuth)) '' + mr ${pkgs.pam_u2f}/lib/security/pam_u2f.so, + '' + + optionalString (isEnabled (cfg: cfg.usbAuth)) '' + mr ${pkgs.pam_usb}/lib/security/pam_usb.so, + '' + + optionalString (isEnabled (cfg: cfg.oathAuth)) '' + "mr ${pkgs.oathToolkit}/lib/security/pam_oath.so, + '' + + optionalString (isEnabled (cfg: cfg.yubicoAuth)) '' + mr ${pkgs.yubico-pam}/lib/security/pam_yubico.so, + '' + + optionalString (isEnabled (cfg: cfg.duoSecurity.enable)) '' + mr ${pkgs.duo-unix}/lib/security/pam_duo.so, + '' + + optionalString (isEnabled (cfg: cfg.otpwAuth)) '' + mr ${pkgs.otpw}/lib/security/pam_otpw.so, + '' + + optionalString config.security.pam.enableEcryptfs '' + mr ${pkgs.ecryptfs}/lib/security/pam_ecryptfs.so, + '' + + optionalString (isEnabled (cfg: cfg.pamMount)) '' + mr ${pkgs.pam_mount}/lib/security/pam_mount.so, + '' + + optionalString (isEnabled (cfg: cfg.enableGnomeKeyring)) '' + mr ${pkgs.gnome3.gnome-keyring}/lib/security/pam_gnome_keyring.so, + '' + + optionalString (isEnabled (cfg: cfg.startSession)) '' + mr ${pkgs.systemd}/lib/security/pam_systemd.so, + '' + + optionalString (isEnabled (cfg: cfg.enableAppArmor) + && config.security.apparmor.enable) '' + mr ${pkgs.apparmor-pam}/lib/security/pam_apparmor.so, + '' + + optionalString (isEnabled (cfg: cfg.enableKwallet)) '' + mr ${pkgs.plasma5Packages.kwallet-pam}/lib/security/pam_kwallet5.so, + '' + + optionalString config.virtualisation.lxc.lxcfs.enable '' + mr ${pkgs.lxc}/lib/security/pam_cgfs.so + ''; + }; + +} diff --git a/nixos/modules/security/pam_mount.nix b/nixos/modules/security/pam_mount.nix new file mode 100644 index 00000000000..462b7f89e2f --- /dev/null +++ b/nixos/modules/security/pam_mount.nix @@ -0,0 +1,102 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + cfg = config.security.pam.mount; + + anyPamMount = any (attrByPath ["pamMount"] false) (attrValues config.security.pam.services); +in + +{ + options = { + + security.pam.mount = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Enable PAM mount system to mount fileystems on user login. + ''; + }; + + extraVolumes = mkOption { + type = types.listOf types.str; + default = []; + description = '' + List of volume definitions for pam_mount. + For more information, visit <link + xlink:href="http://pam-mount.sourceforge.net/pam_mount.conf.5.html" />. + ''; + }; + + additionalSearchPaths = mkOption { + type = types.listOf types.package; + default = []; + example = literalExpression "[ pkgs.bindfs ]"; + description = '' + Additional programs to include in the search path of pam_mount. + Useful for example if you want to use some FUSE filesystems like bindfs. + ''; + }; + + fuseMountOptions = mkOption { + type = types.listOf types.str; + default = []; + example = literalExpression '' + [ "nodev" "nosuid" "force-user=%(USER)" "gid=%(USERGID)" "perms=0700" "chmod-deny" "chown-deny" "chgrp-deny" ] + ''; + description = '' + Global mount options that apply to every FUSE volume. + You can define volume-specific options in the volume definitions. + ''; + }; + }; + + }; + + config = mkIf (cfg.enable || anyPamMount) { + + environment.systemPackages = [ pkgs.pam_mount ]; + environment.etc."security/pam_mount.conf.xml" = { + source = + let + extraUserVolumes = filterAttrs (n: u: u.cryptHomeLuks != null || u.pamMount != {}) config.users.users; + mkAttr = k: v: ''${k}="${v}"''; + userVolumeEntry = user: let + attrs = { + user = user.name; + path = user.cryptHomeLuks; + mountpoint = user.home; + } // user.pamMount; + in + "<volume ${concatStringsSep " " (mapAttrsToList mkAttr attrs)} />\n"; + in + pkgs.writeText "pam_mount.conf.xml" '' + <?xml version="1.0" encoding="utf-8" ?> + <!DOCTYPE pam_mount SYSTEM "pam_mount.conf.xml.dtd"> + <!-- auto generated from Nixos: modules/config/users-groups.nix --> + <pam_mount> + <debug enable="0" /> + + <!-- if activated, requires ofl from hxtools to be present --> + <logout wait="0" hup="no" term="no" kill="no" /> + <!-- set PATH variable for pam_mount module --> + <path>${makeBinPath ([ pkgs.util-linux ] ++ cfg.additionalSearchPaths)}</path> + <!-- create mount point if not present --> + <mkmountpoint enable="1" remove="true" /> + + <!-- specify the binaries to be called --> + <fusemount>${pkgs.fuse}/bin/mount.fuse %(VOLUME) %(MNTPT) -o ${concatStringsSep "," (cfg.fuseMountOptions ++ [ "%(OPTIONS)" ])}</fusemount> + <cryptmount>${pkgs.pam_mount}/bin/mount.crypt %(VOLUME) %(MNTPT)</cryptmount> + <cryptumount>${pkgs.pam_mount}/bin/umount.crypt %(MNTPT)</cryptumount> + <pmvarrun>${pkgs.pam_mount}/bin/pmvarrun -u %(USER) -o %(OPERATION)</pmvarrun> + + ${concatStrings (map userVolumeEntry (attrValues extraUserVolumes))} + ${concatStringsSep "\n" cfg.extraVolumes} + </pam_mount> + ''; + }; + + }; +} diff --git a/nixos/modules/security/pam_usb.nix b/nixos/modules/security/pam_usb.nix new file mode 100644 index 00000000000..51d81e823f8 --- /dev/null +++ b/nixos/modules/security/pam_usb.nix @@ -0,0 +1,52 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.security.pam.usb; + + anyUsbAuth = any (attrByPath ["usbAuth"] false) (attrValues config.security.pam.services); + +in + +{ + options = { + + security.pam.usb = { + enable = mkOption { + type = types.bool; + default = false; + description = '' + Enable USB login for all login systems that support it. For + more information, visit <link + xlink:href="https://github.com/aluzzardi/pam_usb/wiki/Getting-Started#setting-up-devices-and-users" />. + ''; + }; + + }; + + }; + + config = mkIf (cfg.enable || anyUsbAuth) { + + # Make sure pmount and pumount are setuid wrapped. + security.wrappers = { + pmount = + { setuid = true; + owner = "root"; + group = "root"; + source = "${pkgs.pmount.out}/bin/pmount"; + }; + pumount = + { setuid = true; + owner = "root"; + group = "root"; + source = "${pkgs.pmount.out}/bin/pumount"; + }; + }; + + environment.systemPackages = [ pkgs.pmount ]; + + }; +} diff --git a/nixos/modules/security/polkit.nix b/nixos/modules/security/polkit.nix new file mode 100644 index 00000000000..1ba149745c6 --- /dev/null +++ b/nixos/modules/security/polkit.nix @@ -0,0 +1,112 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.security.polkit; + +in + +{ + + options = { + + security.polkit.enable = mkEnableOption "polkit"; + + security.polkit.extraConfig = mkOption { + type = types.lines; + default = ""; + example = + '' + /* Log authorization checks. */ + polkit.addRule(function(action, subject) { + polkit.log("user " + subject.user + " is attempting action " + action.id + " from PID " + subject.pid); + }); + + /* Allow any local user to do anything (dangerous!). */ + polkit.addRule(function(action, subject) { + if (subject.local) return "yes"; + }); + ''; + description = + '' + Any polkit rules to be added to config (in JavaScript ;-). See: + http://www.freedesktop.org/software/polkit/docs/latest/polkit.8.html#polkit-rules + ''; + }; + + security.polkit.adminIdentities = mkOption { + type = types.listOf types.str; + default = [ "unix-group:wheel" ]; + example = [ "unix-user:alice" "unix-group:admin" ]; + description = + '' + Specifies which users are considered “administrators”, for those + actions that require the user to authenticate as an + administrator (i.e. have an <literal>auth_admin</literal> + value). By default, this is all users in the <literal>wheel</literal> group. + ''; + }; + + }; + + + config = mkIf cfg.enable { + + environment.systemPackages = [ pkgs.polkit.bin pkgs.polkit.out ]; + + systemd.packages = [ pkgs.polkit.out ]; + + systemd.services.polkit.restartTriggers = [ config.system.path ]; + systemd.services.polkit.stopIfChanged = false; + + # The polkit daemon reads action/rule files + environment.pathsToLink = [ "/share/polkit-1" ]; + + # PolKit rules for NixOS. + environment.etc."polkit-1/rules.d/10-nixos.rules".text = + '' + polkit.addAdminRule(function(action, subject) { + return [${concatStringsSep ", " (map (i: "\"${i}\"") cfg.adminIdentities)}]; + }); + + ${cfg.extraConfig} + ''; #TODO: validation on compilation (at least against typos) + + services.dbus.packages = [ pkgs.polkit.out ]; + + security.pam.services.polkit-1 = {}; + + security.wrappers = { + pkexec = + { setuid = true; + owner = "root"; + group = "root"; + source = "${pkgs.polkit.bin}/bin/pkexec"; + }; + polkit-agent-helper-1 = + { setuid = true; + owner = "root"; + group = "root"; + source = "${pkgs.polkit.out}/lib/polkit-1/polkit-agent-helper-1"; + }; + }; + + systemd.tmpfiles.rules = [ + # Probably no more needed, clean up + "R /var/lib/polkit-1" + "R /var/lib/PolicyKit" + ]; + + users.users.polkituser = { + description = "PolKit daemon"; + uid = config.ids.uids.polkituser; + group = "polkituser"; + }; + + users.groups.polkituser = {}; + }; + +} + diff --git a/nixos/modules/security/rngd.nix b/nixos/modules/security/rngd.nix new file mode 100644 index 00000000000..8cca1c26d68 --- /dev/null +++ b/nixos/modules/security/rngd.nix @@ -0,0 +1,16 @@ +{ lib, ... }: +let + removed = k: lib.mkRemovedOptionModule [ "security" "rngd" k ]; +in +{ + imports = [ + (removed "enable" '' + rngd is not necessary for any device that the kernel recognises + as an hardware RNG, as it will automatically run the krngd task + to periodically collect random data from the device and mix it + into the kernel's RNG. + '') + (removed "debug" + "The rngd module was removed, so its debug option does nothing.") + ]; +} diff --git a/nixos/modules/security/rtkit.nix b/nixos/modules/security/rtkit.nix new file mode 100644 index 00000000000..ad8746808e8 --- /dev/null +++ b/nixos/modules/security/rtkit.nix @@ -0,0 +1,47 @@ +# A module for ‘rtkit’, a DBus system service that hands out realtime +# scheduling priority to processes that ask for it. + +{ config, lib, pkgs, ... }: + +with lib; + +{ + + options = { + + security.rtkit.enable = mkOption { + type = types.bool; + default = false; + description = '' + Whether to enable the RealtimeKit system service, which hands + out realtime scheduling priority to user processes on + demand. For example, the PulseAudio server uses this to + acquire realtime priority. + ''; + }; + + }; + + + config = mkIf config.security.rtkit.enable { + + security.polkit.enable = true; + + # To make polkit pickup rtkit policies + environment.systemPackages = [ pkgs.rtkit ]; + + systemd.packages = [ pkgs.rtkit ]; + + services.dbus.packages = [ pkgs.rtkit ]; + + users.users.rtkit = + { + isSystemUser = true; + group = "rtkit"; + description = "RealtimeKit daemon"; + }; + users.groups.rtkit = {}; + + }; + +} diff --git a/nixos/modules/security/sudo.nix b/nixos/modules/security/sudo.nix new file mode 100644 index 00000000000..99e578f8ada --- /dev/null +++ b/nixos/modules/security/sudo.nix @@ -0,0 +1,265 @@ +{ config, lib, pkgs, ... }: + +with lib; + +let + + cfg = config.security.sudo; + + inherit (pkgs) sudo; + + toUserString = user: if (isInt user) then "#${toString user}" else "${user}"; + toGroupString = group: if (isInt group) then "%#${toString group}" else "%${group}"; + + toCommandOptionsString = options: + "${concatStringsSep ":" options}${optionalString (length options != 0) ":"} "; + + toCommandsString = commands: + concatStringsSep ", " ( + map (command: + if (isString command) then + command + else + "${toCommandOptionsString command.options}${command.command}" + ) commands + ); + +in + +{ + + ###### interface + + options = { + + security.sudo.enable = mkOption { + type = types.bool; + default = true; + description = + '' + Whether to enable the <command>sudo</command> command, which + allows non-root users to execute commands as root. + ''; + }; + + security.sudo.package = mkOption { + type = types.package; + default = pkgs.sudo; + defaultText = literalExpression "pkgs.sudo"; + description = '' + Which package to use for `sudo`. + ''; + }; + + security.sudo.wheelNeedsPassword = mkOption { + type = types.bool; + default = true; + description = + '' + Whether users of the <code>wheel</code> group must + provide a password to run commands as super user via <command>sudo</command>. + ''; + }; + + security.sudo.execWheelOnly = mkOption { + type = types.bool; + default = false; + description = '' + Only allow members of the <code>wheel</code> group to execute sudo by + setting the executable's permissions accordingly. + This prevents users that are not members of <code>wheel</code> from + exploiting vulnerabilities in sudo such as CVE-2021-3156. + ''; + }; + + security.sudo.configFile = mkOption { + type = types.lines; + # Note: if syntax errors are detected in this file, the NixOS + # configuration will fail to build. + description = + '' + This string contains the contents of the + <filename>sudoers</filename> file. + ''; + }; + + security.sudo.extraRules = mkOption { + description = '' + Define specific rules to be in the <filename>sudoers</filename> file. + More specific rules should come after more general ones in order to + yield the expected behavior. You can use mkBefore/mkAfter to ensure + this is the case when configuration options are merged. + ''; + default = []; + example = literalExpression '' + [ + # Allow execution of any command by all users in group sudo, + # requiring a password. + { groups = [ "sudo" ]; commands = [ "ALL" ]; } + + # Allow execution of "/home/root/secret.sh" by user `backup`, `database` + # and the group with GID `1006` without a password. + { users = [ "backup" "database" ]; groups = [ 1006 ]; + commands = [ { command = "/home/root/secret.sh"; options = [ "SETENV" "NOPASSWD" ]; } ]; } + + # Allow all users of group `bar` to run two executables as user `foo` + # with arguments being pre-set. + { groups = [ "bar" ]; runAs = "foo"; + commands = + [ "/home/baz/cmd1.sh hello-sudo" + { command = '''/home/baz/cmd2.sh ""'''; options = [ "SETENV" ]; } ]; } + ] + ''; + type = with types; listOf (submodule { + options = { + users = mkOption { + type = with types; listOf (either str int); + description = '' + The usernames / UIDs this rule should apply for. + ''; + default = []; + }; + + groups = mkOption { + type = with types; listOf (either str int); + description = '' + The groups / GIDs this rule should apply for. + ''; + default = []; + }; + + host = mkOption { + type = types.str; + default = "ALL"; + description = '' + For what host this rule should apply. + ''; + }; + + runAs = mkOption { + type = with types; str; + default = "ALL:ALL"; + description = '' + Under which user/group the specified command is allowed to run. + + A user can be specified using just the username: <code>"foo"</code>. + It is also possible to specify a user/group combination using <code>"foo:bar"</code> + or to only allow running as a specific group with <code>":bar"</code>. + ''; + }; + + commands = mkOption { + description = '' + The commands for which the rule should apply. + ''; + type = with types; listOf (either str (submodule { + + options = { + command = mkOption { + type = with types; str; + description = '' + A command being either just a path to a binary to allow any arguments, + the full command with arguments pre-set or with <code>""</code> used as the argument, + not allowing arguments to the command at all. + ''; + }; + + options = mkOption { + type = with types; listOf (enum [ "NOPASSWD" "PASSWD" "NOEXEC" "EXEC" "SETENV" "NOSETENV" "LOG_INPUT" "NOLOG_INPUT" "LOG_OUTPUT" "NOLOG_OUTPUT" ]); + description = '' + Options for running the command. Refer to the <a href="https://www.sudo.ws/man/1.7.10/sudoers.man.html">sudo manual</a>. + ''; + default = []; + }; + }; + + })); + }; + }; + }); + }; + + security.sudo.extraConfig = mkOption { + type = types.lines; + default = ""; + description = '' + Extra configuration text appended to <filename>sudoers</filename>. + ''; + }; + }; + + + ###### implementation + + config = mkIf cfg.enable { + + # We `mkOrder 600` so that the default rule shows up first, but there is + # still enough room for a user to `mkBefore` it. + security.sudo.extraRules = mkOrder 600 [ + { groups = [ "wheel" ]; + commands = [ { command = "ALL"; options = (if cfg.wheelNeedsPassword then [ "SETENV" ] else [ "NOPASSWD" "SETENV" ]); } ]; + } + ]; + + security.sudo.configFile = + '' + # Don't edit this file. Set the NixOS options ‘security.sudo.configFile’ + # or ‘security.sudo.extraRules’ instead. + + # Keep SSH_AUTH_SOCK so that pam_ssh_agent_auth.so can do its magic. + Defaults env_keep+=SSH_AUTH_SOCK + + # "root" is allowed to do anything. + root ALL=(ALL:ALL) SETENV: ALL + + # extraRules + ${concatStringsSep "\n" ( + lists.flatten ( + map ( + rule: if (length rule.commands != 0) then [ + (map (user: "${toUserString user} ${rule.host}=(${rule.runAs}) ${toCommandsString rule.commands}") rule.users) + (map (group: "${toGroupString group} ${rule.host}=(${rule.runAs}) ${toCommandsString rule.commands}") rule.groups) + ] else [] + ) cfg.extraRules + ) + )} + + ${cfg.extraConfig} + ''; + + security.wrappers = let + owner = "root"; + group = if cfg.execWheelOnly then "wheel" else "root"; + setuid = true; + permissions = if cfg.execWheelOnly then "u+rx,g+x" else "u+rx,g+x,o+x"; + in { + sudo = { + source = "${cfg.package.out}/bin/sudo"; + inherit owner group setuid permissions; + }; + sudoedit = { + source = "${cfg.package.out}/bin/sudoedit"; + inherit owner group setuid permissions; + }; + }; + + environment.systemPackages = [ sudo ]; + + security.pam.services.sudo = { sshAgentAuth = true; }; + + environment.etc.sudoers = + { source = + pkgs.runCommand "sudoers" + { + src = pkgs.writeText "sudoers-in" cfg.configFile; + preferLocalBuild = true; + } + # Make sure that the sudoers file is syntactically valid. + # (currently disabled - NIXOS-66) + "${pkgs.buildPackages.sudo}/sbin/visudo -f $src -c && cp $src $out"; + mode = "0440"; + }; + + }; + +} diff --git a/nixos/modules/security/systemd-confinement.nix b/nixos/modules/security/systemd-confinement.nix new file mode 100644 index 00000000000..f3a2de3bf87 --- /dev/null +++ b/nixos/modules/security/systemd-confinement.nix @@ -0,0 +1,202 @@ +{ config, pkgs, lib, utils, ... }: + +let + toplevelConfig = config; + inherit (lib) types; + inherit (utils.systemdUtils.lib) mkPathSafeName; +in { + options.systemd.services = lib.mkOption { + type = types.attrsOf (types.submodule ({ name, config, ... }: { + options.confinement.enable = lib.mkOption { + type = types.bool; + default = false; + description = '' + If set, all the required runtime store paths for this service are + bind-mounted into a <literal>tmpfs</literal>-based <citerefentry> + <refentrytitle>chroot</refentrytitle> + <manvolnum>2</manvolnum> + </citerefentry>. + ''; + }; + + options.confinement.fullUnit = lib.mkOption { + type = types.bool; + default = false; + description = '' + Whether to include the full closure of the systemd unit file into the + chroot, instead of just the dependencies for the executables. + + <warning><para>While it may be tempting to just enable this option to + make things work quickly, please be aware that this might add paths + to the closure of the chroot that you didn't anticipate. It's better + to use <option>confinement.packages</option> to <emphasis + role="strong">explicitly</emphasis> add additional store paths to the + chroot.</para></warning> + ''; + }; + + options.confinement.packages = lib.mkOption { + type = types.listOf (types.either types.str types.package); + default = []; + description = let + mkScOption = optName: "<option>serviceConfig.${optName}</option>"; + in '' + Additional packages or strings with context to add to the closure of + the chroot. By default, this includes all the packages from the + ${lib.concatMapStringsSep ", " mkScOption [ + "ExecReload" "ExecStartPost" "ExecStartPre" "ExecStop" + "ExecStopPost" + ]} and ${mkScOption "ExecStart"} options. If you want to have all the + dependencies of this systemd unit, you can use + <option>confinement.fullUnit</option>. + + <note><para>The store paths listed in <option>path</option> are + <emphasis role="strong">not</emphasis> included in the closure as + well as paths from other options except those listed + above.</para></note> + ''; + }; + + options.confinement.binSh = lib.mkOption { + type = types.nullOr types.path; + default = toplevelConfig.environment.binsh; + defaultText = lib.literalExpression "config.environment.binsh"; + example = lib.literalExpression ''"''${pkgs.dash}/bin/dash"''; + description = '' + The program to make available as <filename>/bin/sh</filename> inside + the chroot. If this is set to <literal>null</literal>, no + <filename>/bin/sh</filename> is provided at all. + + This is useful for some applications, which for example use the + <citerefentry> + <refentrytitle>system</refentrytitle> + <manvolnum>3</manvolnum> + </citerefentry> library function to execute commands. + ''; + }; + + options.confinement.mode = lib.mkOption { + type = types.enum [ "full-apivfs" "chroot-only" ]; + default = "full-apivfs"; + description = '' + The value <literal>full-apivfs</literal> (the default) sets up + private <filename class="directory">/dev</filename>, <filename + class="directory">/proc</filename>, <filename + class="directory">/sys</filename> and <filename + class="directory">/tmp</filename> file systems in a separate user + name space. + + If this is set to <literal>chroot-only</literal>, only the file + system name space is set up along with the call to <citerefentry> + <refentrytitle>chroot</refentrytitle> + <manvolnum>2</manvolnum> + </citerefentry>. + + <note><para>This doesn't cover network namespaces and is solely for + file system level isolation.</para></note> + ''; + }; + + config = let + rootName = "${mkPathSafeName name}-chroot"; + inherit (config.confinement) binSh fullUnit; + wantsAPIVFS = lib.mkDefault (config.confinement.mode == "full-apivfs"); + in lib.mkIf config.confinement.enable { + serviceConfig = { + RootDirectory = "/var/empty"; + TemporaryFileSystem = "/"; + PrivateMounts = lib.mkDefault true; + + # https://github.com/NixOS/nixpkgs/issues/14645 is a future attempt + # to change some of these to default to true. + # + # If we run in chroot-only mode, having something like PrivateDevices + # set to true by default will mount /dev within the chroot, whereas + # with "chroot-only" it's expected that there are no /dev, /proc and + # /sys file systems available. + # + # However, if this suddenly becomes true, the attack surface will + # increase, so let's explicitly set these options to true/false + # depending on the mode. + MountAPIVFS = wantsAPIVFS; + PrivateDevices = wantsAPIVFS; + PrivateTmp = wantsAPIVFS; + PrivateUsers = wantsAPIVFS; + ProtectControlGroups = wantsAPIVFS; + ProtectKernelModules = wantsAPIVFS; + ProtectKernelTunables = wantsAPIVFS; + }; + confinement.packages = let + execOpts = [ + "ExecReload" "ExecStart" "ExecStartPost" "ExecStartPre" "ExecStop" + "ExecStopPost" + ]; + execPkgs = lib.concatMap (opt: let + isSet = config.serviceConfig ? ${opt}; + in lib.flatten (lib.optional isSet config.serviceConfig.${opt})) execOpts; + unitAttrs = toplevelConfig.systemd.units."${name}.service"; + allPkgs = lib.singleton (builtins.toJSON unitAttrs); + unitPkgs = if fullUnit then allPkgs else execPkgs; + in unitPkgs ++ lib.optional (binSh != null) binSh; + }; + })); + }; + + config.assertions = lib.concatLists (lib.mapAttrsToList (name: cfg: let + whatOpt = optName: "The 'serviceConfig' option '${optName}' for" + + " service '${name}' is enabled in conjunction with" + + " 'confinement.enable'"; + in lib.optionals cfg.confinement.enable [ + { assertion = !cfg.serviceConfig.RootDirectoryStartOnly or false; + message = "${whatOpt "RootDirectoryStartOnly"}, but right now systemd" + + " doesn't support restricting bind-mounts to 'ExecStart'." + + " Please either define a separate service or find a way to run" + + " commands other than ExecStart within the chroot."; + } + { assertion = !cfg.serviceConfig.DynamicUser or false; + message = "${whatOpt "DynamicUser"}. Please create a dedicated user via" + + " the 'users.users' option instead as this combination is" + + " currently not supported."; + } + { assertion = cfg.serviceConfig ? ProtectSystem -> cfg.serviceConfig.ProtectSystem == false; + message = "${whatOpt "ProtectSystem"}. ProtectSystem is not compatible" + + " with service confinement as it fails to remount /usr within" + + " our chroot. Please disable the option."; + } + ]) config.systemd.services); + + config.systemd.packages = lib.concatLists (lib.mapAttrsToList (name: cfg: let + rootPaths = let + contents = lib.concatStringsSep "\n" cfg.confinement.packages; + in pkgs.writeText "${mkPathSafeName name}-string-contexts.txt" contents; + + chrootPaths = pkgs.runCommand "${mkPathSafeName name}-chroot-paths" { + closureInfo = pkgs.closureInfo { inherit rootPaths; }; + serviceName = "${name}.service"; + excludedPath = rootPaths; + } '' + mkdir -p "$out/lib/systemd/system/$serviceName.d" + serviceFile="$out/lib/systemd/system/$serviceName.d/confinement.conf" + + echo '[Service]' > "$serviceFile" + + # /bin/sh is special here, because the option value could contain a + # symlink and we need to properly resolve it. + ${lib.optionalString (cfg.confinement.binSh != null) '' + binsh=${lib.escapeShellArg cfg.confinement.binSh} + realprog="$(readlink -e "$binsh")" + echo "BindReadOnlyPaths=$realprog:/bin/sh" >> "$serviceFile" + ''} + + while read storePath; do + if [ -L "$storePath" ]; then + # Currently, systemd can't cope with symlinks in Bind(ReadOnly)Paths, + # so let's just bind-mount the target to that location. + echo "BindReadOnlyPaths=$(readlink -e "$storePath"):$storePath" + elif [ "$storePath" != "$excludedPath" ]; then + echo "BindReadOnlyPaths=$storePath" + fi + done < "$closureInfo/store-paths" >> "$serviceFile" + ''; + in lib.optional cfg.confinement.enable chrootPaths) config.systemd.services); +} diff --git a/nixos/modules/security/tpm2.nix b/nixos/modules/security/tpm2.nix new file mode 100644 index 00000000000..be85fd246e3 --- /dev/null +++ b/nixos/modules/security/tpm2.nix @@ -0,0 +1,184 @@ +{ lib, pkgs, config, ... }: +let + cfg = config.security.tpm2; + + # This snippet is taken from tpm2-tss/dist/tpm-udev.rules, but modified to allow custom user/groups + # The idea is that the tssUser is allowed to acess the TPM and kernel TPM resource manager, while + # the tssGroup is only allowed to access the kernel resource manager + # Therefore, if either of the two are null, the respective part isn't generated + udevRules = tssUser: tssGroup: '' + ${lib.optionalString (tssUser != null) ''KERNEL=="tpm[0-9]*", MODE="0660", OWNER="${tssUser}"''} + ${lib.optionalString (tssUser != null || tssGroup != null) + ''KERNEL=="tpmrm[0-9]*", MODE="0660"'' + + lib.optionalString (tssUser != null) '', OWNER="${tssUser}"'' + + lib.optionalString (tssGroup != null) '', GROUP="${tssGroup}"'' + } + ''; + +in { + options.security.tpm2 = { + enable = lib.mkEnableOption "Trusted Platform Module 2 support"; + + tssUser = lib.mkOption { + description = '' + Name of the tpm device-owner and service user, set if applyUdevRules is + set. + ''; + type = lib.types.nullOr lib.types.str; + default = if cfg.abrmd.enable then "tss" else "root"; + defaultText = lib.literalExpression ''if config.security.tpm2.abrmd.enable then "tss" else "root"''; + }; + + tssGroup = lib.mkOption { + description = '' + Group of the tpm kernel resource manager (tpmrm) device-group, set if + applyUdevRules is set. + ''; + type = lib.types.nullOr lib.types.str; + default = "tss"; + }; + + applyUdevRules = lib.mkOption { + description = '' + Whether to make the /dev/tpm[0-9] devices accessible by the tssUser, or + the /dev/tpmrm[0-9] by tssGroup respectively + ''; + type = lib.types.bool; + default = true; + }; + + abrmd = { + enable = lib.mkEnableOption '' + Trusted Platform 2 userspace resource manager daemon + ''; + + package = lib.mkOption { + description = "tpm2-abrmd package to use"; + type = lib.types.package; + default = pkgs.tpm2-abrmd; + defaultText = lib.literalExpression "pkgs.tpm2-abrmd"; + }; + }; + + pkcs11 = { + enable = lib.mkEnableOption '' + TPM2 PKCS#11 tool and shared library in system path + (<literal>/run/current-system/sw/lib/libtpm2_pkcs11.so</literal>) + ''; + + package = lib.mkOption { + description = "tpm2-pkcs11 package to use"; + type = lib.types.package; + default = pkgs.tpm2-pkcs11; + defaultText = lib.literalExpression "pkgs.tpm2-pkcs11"; + }; + }; + + tctiEnvironment = { + enable = lib.mkOption { + description = '' + Set common TCTI environment variables to the specified value. + The variables are + <itemizedlist> + <listitem> + <para> + <literal>TPM2TOOLS_TCTI</literal> + </para> + </listitem> + <listitem> + <para> + <literal>TPM2_PKCS11_TCTI</literal> + </para> + </listitem> + </itemizedlist> + ''; + type = lib.types.bool; + default = false; + }; + + interface = lib.mkOption { + description = '' + The name of the TPM command transmission interface (TCTI) library to + use. + ''; + type = lib.types.enum [ "tabrmd" "device" ]; + default = "device"; + }; + + deviceConf = lib.mkOption { + description = '' + Configuration part of the device TCTI, e.g. the path to the TPM device. + Applies if interface is set to "device". + The format is specified in the + <link xlink:href="https://github.com/tpm2-software/tpm2-tools/blob/master/man/common/tcti.md#tcti-options"> + tpm2-tools repository</link>. + ''; + type = lib.types.str; + default = "/dev/tpmrm0"; + }; + + tabrmdConf = lib.mkOption { + description = '' + Configuration part of the tabrmd TCTI, like the D-Bus bus name. + Applies if interface is set to "tabrmd". + The format is specified in the + <link xlink:href="https://github.com/tpm2-software/tpm2-tools/blob/master/man/common/tcti.md#tcti-options"> + tpm2-tools repository</link>. + ''; + type = lib.types.str; + default = "bus_name=com.intel.tss2.Tabrmd"; + }; + }; + }; + + config = lib.mkIf cfg.enable (lib.mkMerge [ + { + # PKCS11 tools and library + environment.systemPackages = lib.mkIf cfg.pkcs11.enable [ + (lib.getBin cfg.pkcs11.package) + (lib.getLib cfg.pkcs11.package) + ]; + + services.udev.extraRules = lib.mkIf cfg.applyUdevRules + (udevRules cfg.tssUser cfg.tssGroup); + + # Create the tss user and group only if the default value is used + users.users.${cfg.tssUser} = lib.mkIf (cfg.tssUser == "tss") { + isSystemUser = true; + group = "tss"; + }; + users.groups.${cfg.tssGroup} = lib.mkIf (cfg.tssGroup == "tss") {}; + + environment.variables = lib.mkIf cfg.tctiEnvironment.enable ( + lib.attrsets.genAttrs [ + "TPM2TOOLS_TCTI" + "TPM2_PKCS11_TCTI" + ] (_: ''${cfg.tctiEnvironment.interface}:${ + if cfg.tctiEnvironment.interface == "tabrmd" then + cfg.tctiEnvironment.tabrmdConf + else + cfg.tctiEnvironment.deviceConf + }'') + ); + } + + (lib.mkIf cfg.abrmd.enable { + systemd.services."tpm2-abrmd" = { + wantedBy = [ "multi-user.target" ]; + serviceConfig = { + Type = "dbus"; + Restart = "always"; + RestartSec = 30; + BusName = "com.intel.tss2.Tabrmd"; + ExecStart = "${cfg.abrmd.package}/bin/tpm2-abrmd"; + User = "tss"; + Group = "tss"; + }; + }; + + services.dbus.packages = lib.singleton cfg.abrmd.package; + }) + ]); + + meta.maintainers = with lib.maintainers; [ lschuermann ]; +} diff --git a/nixos/modules/security/wrappers/default.nix b/nixos/modules/security/wrappers/default.nix new file mode 100644 index 00000000000..e63f19010de --- /dev/null +++ b/nixos/modules/security/wrappers/default.nix @@ -0,0 +1,305 @@ +{ config, lib, pkgs, ... }: +let + + inherit (config.security) wrapperDir wrappers; + + parentWrapperDir = dirOf wrapperDir; + + securityWrapper = pkgs.callPackage ./wrapper.nix { + inherit parentWrapperDir; + }; + + fileModeType = + let + # taken from the chmod(1) man page + symbolic = "[ugoa]*([-+=]([rwxXst]*|[ugo]))+|[-+=][0-7]+"; + numeric = "[-+=]?[0-7]{0,4}"; + mode = "((${symbolic})(,${symbolic})*)|(${numeric})"; + in + lib.types.strMatching mode + // { description = "file mode string"; }; + + wrapperType = lib.types.submodule ({ name, config, ... }: { + options.source = lib.mkOption + { type = lib.types.path; + description = "The absolute path to the program to be wrapped."; + }; + options.program = lib.mkOption + { type = with lib.types; nullOr str; + default = name; + description = '' + The name of the wrapper program. Defaults to the attribute name. + ''; + }; + options.owner = lib.mkOption + { type = lib.types.str; + description = "The owner of the wrapper program."; + }; + options.group = lib.mkOption + { type = lib.types.str; + description = "The group of the wrapper program."; + }; + options.permissions = lib.mkOption + { type = fileModeType; + default = "u+rx,g+x,o+x"; + example = "a+rx"; + description = '' + The permissions of the wrapper program. The format is that of a + symbolic or numeric file mode understood by <command>chmod</command>. + ''; + }; + options.capabilities = lib.mkOption + { type = lib.types.commas; + default = ""; + description = '' + A comma-separated list of capabilities to be given to the wrapper + program. For capabilities supported by the system check the + <citerefentry> + <refentrytitle>capabilities</refentrytitle> + <manvolnum>7</manvolnum> + </citerefentry> + manual page. + + <note><para> + <literal>cap_setpcap</literal>, which is required for the wrapper + program to be able to raise caps into the Ambient set is NOT raised + to the Ambient set so that the real program cannot modify its own + capabilities!! This may be too restrictive for cases in which the + real program needs cap_setpcap but it at least leans on the side + security paranoid vs. too relaxed. + </para></note> + ''; + }; + options.setuid = lib.mkOption + { type = lib.types.bool; + default = false; + description = "Whether to add the setuid bit the wrapper program."; + }; + options.setgid = lib.mkOption + { type = lib.types.bool; + default = false; + description = "Whether to add the setgid bit the wrapper program."; + }; + }); + + ###### Activation script for the setcap wrappers + mkSetcapProgram = + { program + , capabilities + , source + , owner + , group + , permissions + , ... + }: + '' + cp ${securityWrapper}/bin/security-wrapper "$wrapperDir/${program}" + echo -n "${source}" > "$wrapperDir/${program}.real" + + # Prevent races + chmod 0000 "$wrapperDir/${program}" + chown ${owner}.${group} "$wrapperDir/${program}" + + # Set desired capabilities on the file plus cap_setpcap so + # the wrapper program can elevate the capabilities set on + # its file into the Ambient set. + ${pkgs.libcap.out}/bin/setcap "cap_setpcap,${capabilities}" "$wrapperDir/${program}" + + # Set the executable bit + chmod ${permissions} "$wrapperDir/${program}" + ''; + + ###### Activation script for the setuid wrappers + mkSetuidProgram = + { program + , source + , owner + , group + , setuid + , setgid + , permissions + , ... + }: + '' + cp ${securityWrapper}/bin/security-wrapper "$wrapperDir/${program}" + echo -n "${source}" > "$wrapperDir/${program}.real" + + # Prevent races + chmod 0000 "$wrapperDir/${program}" + chown ${owner}.${group} "$wrapperDir/${program}" + + chmod "u${if setuid then "+" else "-"}s,g${if setgid then "+" else "-"}s,${permissions}" "$wrapperDir/${program}" + ''; + + mkWrappedPrograms = + builtins.map + (opts: + if opts.capabilities != "" + then mkSetcapProgram opts + else mkSetuidProgram opts + ) (lib.attrValues wrappers); +in +{ + imports = [ + (lib.mkRemovedOptionModule [ "security" "setuidOwners" ] "Use security.wrappers instead") + (lib.mkRemovedOptionModule [ "security" "setuidPrograms" ] "Use security.wrappers instead") + ]; + + ###### interface + + options = { + security.wrappers = lib.mkOption { + type = lib.types.attrsOf wrapperType; + default = {}; + example = lib.literalExpression + '' + { + # a setuid root program + doas = + { setuid = true; + owner = "root"; + group = "root"; + source = "''${pkgs.doas}/bin/doas"; + }; + + # a setgid program + locate = + { setgid = true; + owner = "root"; + group = "mlocate"; + source = "''${pkgs.locate}/bin/locate"; + }; + + # a program with the CAP_NET_RAW capability + ping = + { owner = "root"; + group = "root"; + capabilities = "cap_net_raw+ep"; + source = "''${pkgs.iputils.out}/bin/ping"; + }; + } + ''; + description = '' + This option effectively allows adding setuid/setgid bits, capabilities, + changing file ownership and permissions of a program without directly + modifying it. This works by creating a wrapper program under the + <option>security.wrapperDir</option> directory, which is then added to + the shell <literal>PATH</literal>. + ''; + }; + + security.wrapperDir = lib.mkOption { + type = lib.types.path; + default = "/run/wrappers/bin"; + internal = true; + description = '' + This option defines the path to the wrapper programs. It + should not be overriden. + ''; + }; + }; + + ###### implementation + config = { + + assertions = lib.mapAttrsToList + (name: opts: + { assertion = opts.setuid || opts.setgid -> opts.capabilities == ""; + message = '' + The security.wrappers.${name} wrapper is not valid: + setuid/setgid and capabilities are mutually exclusive. + ''; + } + ) wrappers; + + security.wrappers = + let + mkSetuidRoot = source: + { setuid = true; + owner = "root"; + group = "root"; + inherit source; + }; + in + { # These are mount related wrappers that require the +s permission. + fusermount = mkSetuidRoot "${pkgs.fuse}/bin/fusermount"; + fusermount3 = mkSetuidRoot "${pkgs.fuse3}/bin/fusermount3"; + mount = mkSetuidRoot "${lib.getBin pkgs.util-linux}/bin/mount"; + umount = mkSetuidRoot "${lib.getBin pkgs.util-linux}/bin/umount"; + }; + + boot.specialFileSystems.${parentWrapperDir} = { + fsType = "tmpfs"; + options = [ "nodev" "mode=755" ]; + }; + + # Make sure our wrapperDir exports to the PATH env variable when + # initializing the shell + environment.extraInit = '' + # Wrappers override other bin directories. + export PATH="${wrapperDir}:$PATH" + ''; + + security.apparmor.includes."nixos/security.wrappers" = '' + include "${pkgs.apparmorRulesFromClosure { name="security.wrappers"; } [ + securityWrapper + ]}" + ''; + + ###### wrappers activation script + system.activationScripts.wrappers = + lib.stringAfter [ "specialfs" "users" ] + '' + chmod 755 "${parentWrapperDir}" + + # We want to place the tmpdirs for the wrappers to the parent dir. + wrapperDir=$(mktemp --directory --tmpdir="${parentWrapperDir}" wrappers.XXXXXXXXXX) + chmod a+rx "$wrapperDir" + + ${lib.concatStringsSep "\n" mkWrappedPrograms} + + if [ -L ${wrapperDir} ]; then + # Atomically replace the symlink + # See https://axialcorps.com/2013/07/03/atomically-replacing-files-and-directories/ + old=$(readlink -f ${wrapperDir}) + if [ -e "${wrapperDir}-tmp" ]; then + rm --force --recursive "${wrapperDir}-tmp" + fi + ln --symbolic --force --no-dereference "$wrapperDir" "${wrapperDir}-tmp" + mv --no-target-directory "${wrapperDir}-tmp" "${wrapperDir}" + rm --force --recursive "$old" + else + # For initial setup + ln --symbolic "$wrapperDir" "${wrapperDir}" + fi + ''; + + ###### wrappers consistency checks + system.extraDependencies = lib.singleton (pkgs.runCommandLocal + "ensure-all-wrappers-paths-exist" { } + '' + # make sure we produce output + mkdir -p $out + + echo -n "Checking that Nix store paths of all wrapped programs exist... " + + declare -A wrappers + ${lib.concatStringsSep "\n" (lib.mapAttrsToList (n: v: + "wrappers['${n}']='${v.source}'") wrappers)} + + for name in "''${!wrappers[@]}"; do + path="''${wrappers[$name]}" + if [[ "$path" =~ /nix/store ]] && [ ! -e "$path" ]; then + test -t 1 && echo -ne '\033[1;31m' + echo "FAIL" + echo "The path $path does not exist!" + echo 'Please, check the value of `security.wrappers."'$name'".source`.' + test -t 1 && echo -ne '\033[0m' + exit 1 + fi + done + + echo "OK" + ''); + }; +} diff --git a/nixos/modules/security/wrappers/wrapper.c b/nixos/modules/security/wrappers/wrapper.c new file mode 100644 index 00000000000..529669facda --- /dev/null +++ b/nixos/modules/security/wrappers/wrapper.c @@ -0,0 +1,233 @@ +#include <stdlib.h> +#include <stdio.h> +#include <string.h> +#include <unistd.h> +#include <sys/types.h> +#include <sys/stat.h> +#include <sys/xattr.h> +#include <fcntl.h> +#include <dirent.h> +#include <assert.h> +#include <errno.h> +#include <linux/capability.h> +#include <sys/prctl.h> +#include <limits.h> +#include <stdint.h> +#include <syscall.h> +#include <byteswap.h> + +// Make sure assertions are not compiled out, we use them to codify +// invariants about this program and we want it to fail fast and +// loudly if they are violated. +#undef NDEBUG + +extern char **environ; + +// The WRAPPER_DIR macro is supplied at compile time so that it cannot +// be changed at runtime +static char *wrapper_dir = WRAPPER_DIR; + +// Wrapper debug variable name +static char *wrapper_debug = "WRAPPER_DEBUG"; + +#define CAP_SETPCAP 8 + +#if __BYTE_ORDER == __BIG_ENDIAN +#define LE32_TO_H(x) bswap_32(x) +#else +#define LE32_TO_H(x) (x) +#endif + +int get_last_cap(unsigned *last_cap) { + FILE* file = fopen("/proc/sys/kernel/cap_last_cap", "r"); + if (file == NULL) { + int saved_errno = errno; + fprintf(stderr, "failed to open /proc/sys/kernel/cap_last_cap: %s\n", strerror(errno)); + return -saved_errno; + } + int res = fscanf(file, "%u", last_cap); + if (res == EOF) { + int saved_errno = errno; + fprintf(stderr, "could not read number from /proc/sys/kernel/cap_last_cap: %s\n", strerror(errno)); + return -saved_errno; + } + fclose(file); + return 0; +} + +// Given the path to this program, fetch its configured capability set +// (as set by `setcap ... /path/to/file`) and raise those capabilities +// into the Ambient set. +static int make_caps_ambient(const char *self_path) { + struct vfs_ns_cap_data data = {}; + int r = getxattr(self_path, "security.capability", &data, sizeof(data)); + + if (r < 0) { + if (errno == ENODATA) { + // no capabilities set + return 0; + } + fprintf(stderr, "cannot get capabilities for %s: %s", self_path, strerror(errno)); + return 1; + } + + size_t size; + uint32_t version = LE32_TO_H(data.magic_etc) & VFS_CAP_REVISION_MASK; + switch (version) { + case VFS_CAP_REVISION_1: + size = VFS_CAP_U32_1; + break; + case VFS_CAP_REVISION_2: + case VFS_CAP_REVISION_3: + size = VFS_CAP_U32_3; + break; + default: + fprintf(stderr, "BUG! Unsupported capability version 0x%x on %s. Report to NixOS bugtracker\n", version, self_path); + return 1; + } + + const struct __user_cap_header_struct header = { + .version = _LINUX_CAPABILITY_VERSION_3, + .pid = getpid(), + }; + struct __user_cap_data_struct user_data[2] = {}; + + for (size_t i = 0; i < size; i++) { + // merge inheritable & permitted into one + user_data[i].permitted = user_data[i].inheritable = + LE32_TO_H(data.data[i].inheritable) | LE32_TO_H(data.data[i].permitted); + } + + if (syscall(SYS_capset, &header, &user_data) < 0) { + fprintf(stderr, "failed to inherit capabilities: %s", strerror(errno)); + return 1; + } + unsigned last_cap; + r = get_last_cap(&last_cap); + if (r < 0) { + return 1; + } + uint64_t set = user_data[0].permitted | (uint64_t)user_data[1].permitted << 32; + for (unsigned cap = 0; cap < last_cap; cap++) { + if (!(set & (1ULL << cap))) { + continue; + } + + // Check for the cap_setpcap capability, we set this on the + // wrapper so it can elevate the capabilities to the Ambient + // set but we do not want to propagate it down into the + // wrapped program. + // + // TODO: what happens if that's the behavior you want + // though???? I'm preferring a strict vs. loose policy here. + if (cap == CAP_SETPCAP) { + if(getenv(wrapper_debug)) { + fprintf(stderr, "cap_setpcap in set, skipping it\n"); + } + continue; + } + if (prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, (unsigned long) cap, 0, 0)) { + fprintf(stderr, "cannot raise the capability %d into the ambient set: %s\n", cap, strerror(errno)); + return 1; + } + if (getenv(wrapper_debug)) { + fprintf(stderr, "raised %d into the ambient capability set\n", cap); + } + } + + return 0; +} + +int readlink_malloc(const char *p, char **ret) { + size_t l = FILENAME_MAX+1; + int r; + + for (;;) { + char *c = calloc(l, sizeof(char)); + if (!c) { + return -ENOMEM; + } + + ssize_t n = readlink(p, c, l-1); + if (n < 0) { + r = -errno; + free(c); + return r; + } + + if ((size_t) n < l-1) { + c[n] = 0; + *ret = c; + return 0; + } + + free(c); + l *= 2; + } +} + +int main(int argc, char **argv) { + char *self_path = NULL; + int self_path_size = readlink_malloc("/proc/self/exe", &self_path); + if (self_path_size < 0) { + fprintf(stderr, "cannot readlink /proc/self/exe: %s", strerror(-self_path_size)); + } + + // Make sure that we are being executed from the right location, + // i.e., `safe_wrapper_dir'. This is to prevent someone from creating + // hard link `X' from some other location, along with a false + // `X.real' file, to allow arbitrary programs from being executed + // with elevated capabilities. + int len = strlen(wrapper_dir); + if (len > 0 && '/' == wrapper_dir[len - 1]) + --len; + assert(!strncmp(self_path, wrapper_dir, len)); + assert('/' == wrapper_dir[0]); + assert('/' == self_path[len]); + + // Make *really* *really* sure that we were executed as + // `self_path', and not, say, as some other setuid program. That + // is, our effective uid/gid should match the uid/gid of + // `self_path'. + struct stat st; + assert(lstat(self_path, &st) != -1); + + assert(!(st.st_mode & S_ISUID) || (st.st_uid == geteuid())); + assert(!(st.st_mode & S_ISGID) || (st.st_gid == getegid())); + + // And, of course, we shouldn't be writable. + assert(!(st.st_mode & (S_IWGRP | S_IWOTH))); + + // Read the path of the real (wrapped) program from <self>.real. + char real_fn[PATH_MAX + 10]; + int real_fn_size = snprintf(real_fn, sizeof(real_fn), "%s.real", self_path); + assert(real_fn_size < sizeof(real_fn)); + + int fd_self = open(real_fn, O_RDONLY); + assert(fd_self != -1); + + char source_prog[PATH_MAX]; + len = read(fd_self, source_prog, PATH_MAX); + assert(len != -1); + assert(len < sizeof(source_prog)); + assert(len > 0); + source_prog[len] = 0; + + close(fd_self); + + // Read the capabilities set on the wrapper and raise them in to + // the ambient set so the program we're wrapping receives the + // capabilities too! + if (make_caps_ambient(self_path) != 0) { + free(self_path); + return 1; + } + free(self_path); + + execve(source_prog, argv, environ); + + fprintf(stderr, "%s: cannot run `%s': %s\n", + argv[0], source_prog, strerror(errno)); + + return 1; +} diff --git a/nixos/modules/security/wrappers/wrapper.nix b/nixos/modules/security/wrappers/wrapper.nix new file mode 100644 index 00000000000..e3620fb222d --- /dev/null +++ b/nixos/modules/security/wrappers/wrapper.nix @@ -0,0 +1,21 @@ +{ stdenv, linuxHeaders, parentWrapperDir, debug ? false }: +# For testing: +# $ nix-build -E 'with import <nixpkgs> {}; pkgs.callPackage ./wrapper.nix { parentWrapperDir = "/run/wrappers"; debug = true; }' +stdenv.mkDerivation { + name = "security-wrapper"; + buildInputs = [ linuxHeaders ]; + dontUnpack = true; + hardeningEnable = [ "pie" ]; + CFLAGS = [ + ''-DWRAPPER_DIR="${parentWrapperDir}"'' + ] ++ (if debug then [ + "-Werror" "-Og" "-g" + ] else [ + "-Wall" "-O2" + ]); + dontStrip = debug; + installPhase = '' + mkdir -p $out/bin + $CC $CFLAGS ${./wrapper.c} -o $out/bin/security-wrapper + ''; +} |