1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
|
.Dd November 9, 2026
.Dt SECURITY 7
.Os
.Sh NAME
.Nm security
.Nd sandboxing the systemd user units of this tree, and what was measured
.Sh DESCRIPTION
The services in this tree run as systemd
.Em user
units on rolery, as the same uid as everything else in the session.
This page is the guideline for confining them, and the record of what was
actually measured while doing so: under a user manager several options are
accepted, scored, and do nothing.
.Pp
The goal is narrow and worth stating: a service that is compromised \(en
through mail it renders, a reply it parses, a dependency \(en must not reach
any secret, file or bus that is not its own.
It is not a defence against other code running as the same user; see
.Sx LIMITS .
.Sh SECRETS
A unit never gets the gpg agent
.Pq Ev GNUPGHOME , Xr pass 1 .
An unlocked agent decrypts every entry in the store, and its git history, for
whoever asks, so a service holding it is one bug away from all of them.
.Pp
Each secret is a TPM-sealed systemd credential, and the unit records where it
was derived from:
.Bd -literal -offset indent
X-CredentialSource=mailweb-profpatsch-imap pass:email/mailbox.org
LoadCredentialEncrypted=mailweb-profpatsch-imap
ExecStart=\&... "--imap-pass-cmd=cat %d/mailweb-profpatsch-imap"
.Ed
.Pp
systemd ignores keys beginning with
.Li X- .
An id without a path is looked up in
.Pa ~/.config/credstore.encrypted/ ;
.Li %d
is the credentials directory.
The name sealed into a credential is checked against the id it is loaded
under, so a misnamed copy is refused at start.
Creating and rotating one is described in
.Pa CLAUDE.md ,
.Dq Service secrets .
.Sh THE BASELINE
Every unit starts from this set, then binds back what it needs.
Each line below was checked by trying the forbidden thing inside a transient
unit, not by reading the score; see
.Sx MEASURING .
.Bd -literal -offset indent
ProtectHome=tmpfs
BindReadOnlyPaths=%h/.nix-profile %h/.local/state/nix/profiles
ProtectSystem=strict
PrivateTmp=yes
PrivateDevices=yes
PrivateIPC=yes
NoNewPrivileges=yes
RestrictSUIDSGID=yes
RestrictNamespaces=yes
RestrictRealtime=yes
LockPersonality=yes
MemoryDenyWriteExecute=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
ProtectHostname=yes
ProtectProc=invisible
CapabilityBoundingSet=
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
RestrictAddressFamilies=AF_UNIX
UMask=0077
.Ed
.Pp
plus
.Li PrivateNetwork=yes
for anything that does not talk to the outside, and
.Li AF_INET AF_INET6
in
.Li RestrictAddressFamilies=
for what does.
.Pp
.Li ProtectHome=tmpfs
replaces home with an empty directory, which also hides the runtime directory
.Pa /run/user/<uid>
and with it the session bus, the gpg agent socket and the other units'
sockets.
The two
.Li BindReadOnlyPaths=
entries are what keeps
.Li ExecStart=%h/.nix-profile/bin/...
resolvable:
.Pa ~/.nix-profile
is a symlink into
.Pa ~/.local/state/nix/profiles ,
and both must be mounted.
.Ss Measured, not yet applied
These were shown to work in a user unit and are not yet in the units:
.Bl -tag -width Ds
.It Li PrivatePIDs=yes
The unit sees one process instead of all of them.
Without it,
.Li ProtectProc=invisible
hides nothing: it hides other users' processes, and every process in the
session is the same user's.
Untested together with socket activation, which passes
.Ev LISTEN_PID ,
and with
.Li Type=notify ;
try those two cases first.
.It Li ProcSubset=pid
Removes
.Pa /proc/sys
and the other non-process files.
.It Li NoExecPaths=/ /tmp No and Li ExecPaths=/nix/store
Nothing written at runtime can be executed.
.Pa /tmp
has to be named separately: with
.Li PrivateTmp=yes
it is a mount of its own, and
.Li NoExecPaths=/
alone leaves it executable.
.It Li KeyringMode=private
The default for user units already; worth stating so it does not change
silently.
.El
.Pp
.Li MemoryDenyWriteExecute=yes
is in the baseline but missing from the mailweb, calweb and
mastodon-alt-text units.
Go does not need writable executable memory; mastodon-alt-text runs
.Xr magick 1 ,
which should be tried first.
.Sh WHAT DOES NOT WORK IN USER UNITS
A user manager cannot attach eBPF programs to its cgroups.
Options implemented that way are accepted without a word in the journal,
counted as protection by
.Ic systemd-analyze security ,
and not enforced:
.Bl -tag -width Ds
.It Li DevicePolicy= , DeviceAllow=
Measured: with
.Li DevicePolicy=closed
and
.Li DeviceAllow=char-i2c rw ,
.Pa /dev/snd
was still readable.
Use
.Li PrivateDevices=yes
and bind the device nodes back in instead, see
.Sx PATTERNS .
.It Li IPAddressDeny= , IPAddressAllow=
Measured: with
.Li IPAddressDeny=any
a connection to 1.1.1.1:443 still succeeded.
Only
.Li PrivateNetwork=yes
removes the network; there is no way to allow some hosts and not others.
.El
.Pp
Also unavailable or meaningless here:
.Li DynamicUser=
and every other way to a separate uid,
.Li RootDirectory= / RootImage= ,
and
.Li PrivateUsers=
beyond the user namespace
.Li ProtectHome=tmpfs
already creates.
These need system units, declared in
.Pa machines/profpatsch/rolery.nix .
.Sh ISOLATION BETWEEN UNITS
The mount options give each unit its own user namespace.
That is what keeps one sandboxed unit out of another: from a unit with the
baseline,
.Pa /proc/<calweb>/root/run/user/1000/credentials/calweb.service/
was not accessible
.Pq Permission denied ,
and with
.Li PrivatePIDs=yes
not even visible.
From a shell, or from a unit without
.Li ProtectHome= ,
the same file was readable.
.Pp
So the sockets, files and credentials of one unit are reachable from another
only if they are bound into it.
Visibility is the permission; nothing checks who connects to a socket.
.Sh PATTERNS
.Ss Bind directories, not files
A bind-mounted file can be read and written, but not created, deleted or
renamed over.
.Bl -bullet
.It
SQLite in WAL mode creates
.Pa <db>-wal
and
.Pa <db>-shm
at open and deletes them at a clean close.
Binding the three files works until the first clean shutdown, after which the
database no longer opens
.Pq Dq unable to open database file .
Give each database a directory of its own and bind that: mailweb has
.Pa ~/.local/share/mailweb/<account>/ ,
one per unit.
.It
A file that is replaced by writing a new one and renaming it needs its
directory bound, which then exposes the directory's other files too
.Pq alacritty-change-color-scheme and Pa ~/.config/alacritty .
.El
.Pp
Where a program has its own state directory,
.Li StateDirectory= , CacheDirectory= , RuntimeDirectory=
are better still: systemd creates them, outside the sandbox, and binds only
them in.
.Ss Sockets come from systemd
A socket a sandboxed service binds itself is created in its private view of
the runtime directory, where no client can reach it.
Every service with a socket therefore gets a
.Li .socket
unit at the path clients already use, and takes the descriptor:
varlink services through
.Fn varlink.Listen ,
see
.Xr varlink-tiny-go 7 .
The service loses its
.Li [Install]
section; the socket is what is enabled.
.Pp
.Li Type=notify
needs the notify socket, which the hidden runtime directory takes away:
.Li BindPaths=%t/systemd/notify
.Pq mastodon-alt-text .
.Ss Splitting by privilege
When one program needs two things that should not meet \(en a cookie jar
holding every site's sessions and the network \(en split it.
claude-usage runs its cookie extractor from a socket unit with
.Li Accept=yes ,
one instance per connection with the connection as stdin/stdout,
.Li PrivateNetwork=yes
and the jar read-only; the half with the network asks it over that socket
and never sees the jar.
.Ss The session bus
.Li org.freedesktop.systemd1
is on the session bus.
Whatever can call it can start any unit or run any command through
.Xr systemd-run 1 ,
outside every sandbox; the secret service and the keyring are there too.
A unit never gets the real session bus.
If it needs something on it, it gets a filtered one from
.Xr xdg-dbus-proxy 1 ,
run as a companion unit, on which only the named calls and signals exist:
.Bd -literal -offset indent
xdg-dbus-proxy $DBUS_SESSION_BUS_ADDRESS %t/<name>-bus/bus --filter \e
--call=org.freedesktop.portal.Desktop=org.freedesktop.portal.Settings.ReadOne@/org/freedesktop/portal/desktop \e
--broadcast=org.freedesktop.portal.Desktop=org.freedesktop.portal.Settings.SettingChanged@/org/freedesktop/portal/desktop
.Ed
.Pp
Measured: through that bus,
.Li systemd1
and
.Li org.freedesktop.secrets
are
.Li ServiceUnknown
and every other portal method is
.Dq Access denied .
alacritty-change-color-scheme has the worked example.
The system bus stays reachable; logind's
.Li Session.SetBrightness
on it is how blight sets the backlight without write access to
.Pa /sys .
.Ss Devices
.Li PrivateDevices=yes
leaves only the pseudo devices; the nodes a unit needs are bound back:
.Bd -literal -offset indent
PrivateDevices=yes
BindPaths=-/dev/i2c-0 -/dev/i2c-1 \&... -/dev/i2c-15
.Ed
.Pp
The leading
.Li -
makes a node that does not exist harmless, so a machine with fewer buses does
not fail the unit.
.Ss One Exec line with PrivateIPC
Under a user manager, a second namespaced Exec line in the same unit
.Pq an Li ExecStartPre=
fails with
.Dq Failed to set up mount namespacing: /dev/mqueue: Operation not permitted
when
.Li PrivateIPC=yes
is set.
Put waiting and setup into the unit that is waited for
.Pq Li ExecStartPost= No there ,
not into the client.
.Sh MEASURING
.Ic systemd-analyze security --user <unit>
reads the unit file.
It is useful for finding what is missing, and says nothing about what is
enforced: it scores the two options in
.Sx WHAT DOES NOT WORK IN USER UNITS
like any other.
.Pp
To know, try the forbidden thing under the unit's options in a transient
unit, and compare with the same command without them:
.Bd -literal -offset indent
$ systemd-run --user --wait --pipe --quiet \e
-p ProtectHome=tmpfs -p PrivateDevices=yes \e
-- sh -c 'ls ~/.password-store; : < /dev/snd/controlC0 && echo OPEN'
.Ed
.Pp
The checks used for this page:
.Bl -tag -width Ds -compact
.It home
.Pa ~/.gnupg , ~/.password-store
absent
.It runtime directory
.Pa /run/user/<uid>/bus
absent
.It devices
opening
.Pa /dev/snd/controlC0
fails
.It network
connecting to 1.1.1.1:443 fails
.It processes
the number of pids in
.Pa /proc
.It exec
a script written to
.Pa /tmp
does not run
.It W+X memory
.Fn mmap
with
.Dv PROT_WRITE | PROT_EXEC
fails
.It system calls
.Fn sethostname ,
.Fn unshare CLONE_NEWUSER ,
.Fn pivot_root
fail or the process is killed with
.Dv SIGSYS
.It other units
.Pa /proc/<pid>/root/...
of another unit's process is not accessible
.El
.Pp
Run a check that may be killed by the seccomp filter in a child process,
or the first such check ends the measurement.
.Pp
Two traps of
.Xr systemd-run 1 :
.Li -p
does not expand specifiers, so
.Li %h
and
.Li \&%C
must be given as literal paths; and
.Li WantedBy=
cannot be set on a transient unit, so drop it when copying a unit's options.
A socket file left behind by a transient unit that bound its own socket has
to be removed by hand.
.Sh LIMITS
Code running as the user, outside every sandbox, can still
.Bl -bullet -compact
.It
read a running unit's decrypted credentials under
.Pa /run/user/<uid>/credentials/<unit>/ ,
.It
decrypt any
.Pa .cred
file with
.Xr systemd-creds 1 ,
.It
read every unit's state directory, and
.It
ask the gpg agent for anything in the password store.
.El
.Pp
That includes an agent working in this repository.
Closing it needs the agent itself in a sandbox, or the services on a
different uid.
.Pp
An X11 client can read every window and every key press.
mastodon-alt-text needs the clipboard, and so keeps
.Pa ~/.Xauthority
and the X server's abstract socket, which no mount namespace hides.
.Pp
Secrets that were ever in the password store are still in its git history.
Moving a service to a credential does not change that; rotating the secret
at the provider does.
.Sh FILES
.Bl -tag -width Ds
.It Pa ~/.config/systemd/user/
The installed units, copies of the ones next to each project's sources.
.It Pa ~/.config/credstore.encrypted/
The credentials.
.El
.Sh SEE ALSO
.Xr systemd-analyze 1 ,
.Xr systemd-creds 1 ,
.Xr systemd-run 1 ,
.Xr xdg-dbus-proxy 1 ,
.Xr systemd.exec 5 ,
.Xr systemd.socket 5 ,
.Xr varlink-tiny-go 7
.Pp
The units this page was written against:
mailweb-*, calweb and mastodon-alt-text (credentials), webchat,
claude-usage (split by privilege), agent-last-position and
display-brightness-control (socket activation, devices),
alacritty-change-color-scheme (filtered session bus).
|