Skip to main content

max / alloy

Answer polkit in the console, in Akari, without tearing the screen down Tier 3 of wiki alloy-privilege. The console registers an authentication agent for its own process, polkit's question arrives as a modal naming the action, and the command runs on a thread beside the event loop so the TUI stays up. The PAM conversation is not ours and that is the point: it runs in polkit's setuid polkit-agent-helper-1, whose protocol is plain text on stdin and stdout. Alloy supplies the cookie, draws the prompt, and hands back what was typed. It decides nothing about whether an answer was right, so what ships is a drawing rather than an authentication surface. Two things the parsers get right because they were written to be attacked. The subject's start-time is read past the comm field rather than by counting whitespace, since comm is the basename of whatever was executed and can hold spaces and parentheses. And the identity asked for is the user's own before any other administrator: polkit offers every one of them, and asking for the first would teach a laptop owner to type the root password into whatever is on screen. Verified against the live polkit on fw13, not only in unit tests: the agent registers, pkcheck's question reaches it, the helper runs, its PAM prompt arrives as a Prompt, and dismissing it comes back as a refusal. Registration takes the object path as a plain string — polkit rejects the typed form, which nothing catches at compile time.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-08-16 00:39 UTC
Signed with PGP, not checked
Commit: 90ade9c22a75d341bed969a352ad0a785bd7bfd5
Parent: 55d4491
8 files changed, +1274 insertions, -68 deletions
M Cargo.lock +371
@@ -34,6 +34,7 @@
34 34 "sha2 0.10.9",
35 35 "toml",
36 36 "toml_edit",
37 + "zbus",
37 38 ]
38 39
39 40 [[package]]
@@ -113,6 +114,137 @@
113 114 "num-traits",
114 115 ]
115 116
117 + [[package]]
118 + name = "async-broadcast"
119 + version = "0.7.2"
120 + source = "registry+https://github.com/rust-lang/crates.io-index"
121 + checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
122 + dependencies = [
123 + "event-listener",
124 + "event-listener-strategy",
125 + "futures-core",
126 + "pin-project-lite",
127 + ]
128 +
129 + [[package]]
130 + name = "async-channel"
131 + version = "2.5.0"
132 + source = "registry+https://github.com/rust-lang/crates.io-index"
133 + checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
134 + dependencies = [
135 + "concurrent-queue",
136 + "event-listener-strategy",
137 + "futures-core",
138 + "pin-project-lite",
139 + ]
140 +
141 + [[package]]
142 + name = "async-executor"
143 + version = "1.14.0"
144 + source = "registry+https://github.com/rust-lang/crates.io-index"
145 + checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a"
146 + dependencies = [
147 + "async-task",
148 + "concurrent-queue",
149 + "fastrand",
150 + "futures-lite",
151 + "pin-project-lite",
152 + "slab",
153 + ]
154 +
155 + [[package]]
156 + name = "async-io"
157 + version = "2.6.0"
158 + source = "registry+https://github.com/rust-lang/crates.io-index"
159 + checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc"
160 + dependencies = [
161 + "autocfg",
162 + "cfg-if",
163 + "concurrent-queue",
164 + "futures-io",
165 + "futures-lite",
166 + "parking",
167 + "polling",
168 + "rustix",
169 + "slab",
170 + "windows-sys",
171 + ]
172 +
173 + [[package]]
174 + name = "async-lock"
175 + version = "3.4.2"
176 + source = "registry+https://github.com/rust-lang/crates.io-index"
177 + checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311"
178 + dependencies = [
179 + "event-listener",
180 + "event-listener-strategy",
181 + "pin-project-lite",
182 + ]
183 +
184 + [[package]]
185 + name = "async-process"
186 + version = "2.5.0"
187 + source = "registry+https://github.com/rust-lang/crates.io-index"
188 + checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75"
189 + dependencies = [
190 + "async-channel",
191 + "async-io",
192 + "async-lock",
193 + "async-signal",
194 + "async-task",
195 + "blocking",
196 + "cfg-if",
197 + "event-listener",
198 + "futures-lite",
199 + "rustix",
200 + ]
201 +
202 + [[package]]
203 + name = "async-recursion"
204 + version = "1.1.1"
205 + source = "registry+https://github.com/rust-lang/crates.io-index"
206 + checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
207 + dependencies = [
208 + "proc-macro2",
209 + "quote",
210 + "syn 2.0.118",
211 + ]
212 +
213 + [[package]]
214 + name = "async-signal"
215 + version = "0.2.14"
216 + source = "registry+https://github.com/rust-lang/crates.io-index"
217 + checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485"
218 + dependencies = [
219 + "async-io",
220 + "async-lock",
221 + "atomic-waker",
222 + "cfg-if",
223 + "futures-core",
224 + "futures-io",
225 + "rustix",
226 + "signal-hook-registry",
227 + "slab",
228 + "windows-sys",
229 + ]
230 +
231 + [[package]]
232 + name = "async-task"
233 + version = "4.7.1"
234 + source = "registry+https://github.com/rust-lang/crates.io-index"
235 + checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de"
236 +
237 + [[package]]
238 + name = "async-trait"
239 + version = "0.1.92"
240 + source = "registry+https://github.com/rust-lang/crates.io-index"
241 + checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
242 + dependencies = [
243 + "proc-macro2",
244 + "quote",
245 + "syn 3.0.0",
246 + ]
247 +
116 248 [[package]]
117 249 name = "atomic"
118 250 version = "0.6.1"
@@ -122,6 +254,12 @@
122 254 "bytemuck",
123 255 ]
124 256
257 + [[package]]
258 + name = "atomic-waker"
259 + version = "1.1.2"
260 + source = "registry+https://github.com/rust-lang/crates.io-index"
261 + checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
262 +
125 263 [[package]]
126 264 name = "autocfg"
127 265 version = "1.5.1"
@@ -185,6 +323,19 @@
185 323 "hybrid-array",
186 324 ]
187 325
326 + [[package]]
327 + name = "blocking"
328 + version = "1.6.2"
329 + source = "registry+https://github.com/rust-lang/crates.io-index"
330 + checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
331 + dependencies = [
332 + "async-channel",
333 + "async-task",
334 + "futures-io",
335 + "futures-lite",
336 + "piper",
337 + ]
338 +
188 339 [[package]]
189 340 name = "bumpalo"
190 341 version = "3.20.3"
@@ -300,6 +451,15 @@
300 451 "static_assertions",
301 452 ]
302 453
454 + [[package]]
455 + name = "concurrent-queue"
456 + version = "2.5.0"
457 + source = "registry+https://github.com/rust-lang/crates.io-index"
458 + checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973"
459 + dependencies = [
460 + "crossbeam-utils",
461 + ]
462 +
303 463 [[package]]
304 464 name = "convert_case"
305 465 version = "0.10.0"
@@ -333,6 +493,12 @@
333 493 source = "registry+https://github.com/rust-lang/crates.io-index"
334 494 checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
335 495
496 + [[package]]
497 + name = "crossbeam-utils"
498 + version = "0.8.22"
499 + source = "registry+https://github.com/rust-lang/crates.io-index"
500 + checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17"
501 +
336 502 [[package]]
337 503 name = "crossterm"
338 504 version = "0.29.0"
@@ -502,6 +668,33 @@
502 668 source = "registry+https://github.com/rust-lang/crates.io-index"
503 669 checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e"
504 670
671 + [[package]]
672 + name = "endi"
673 + version = "1.1.1"
674 + source = "registry+https://github.com/rust-lang/crates.io-index"
675 + checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
676 +
677 + [[package]]
678 + name = "enumflags2"
679 + version = "0.7.12"
680 + source = "registry+https://github.com/rust-lang/crates.io-index"
681 + checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
682 + dependencies = [
683 + "enumflags2_derive",
684 + "serde",
685 + ]
686 +
687 + [[package]]
688 + name = "enumflags2_derive"
689 + version = "0.7.12"
690 + source = "registry+https://github.com/rust-lang/crates.io-index"
691 + checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
692 + dependencies = [
693 + "proc-macro2",
694 + "quote",
695 + "syn 2.0.118",
696 + ]
697 +
505 698 [[package]]
506 699 name = "equivalent"
507 700 version = "1.0.2"
@@ -527,6 +720,26 @@
527 720 "num-traits",
528 721 ]
529 722
723 + [[package]]
724 + name = "event-listener"
725 + version = "5.4.2"
726 + source = "registry+https://github.com/rust-lang/crates.io-index"
727 + checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
728 + dependencies = [
729 + "parking",
730 + "pin-project-lite",
731 + ]
732 +
733 + [[package]]
734 + name = "event-listener-strategy"
735 + version = "0.5.4"
736 + source = "registry+https://github.com/rust-lang/crates.io-index"
737 + checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93"
738 + dependencies = [
739 + "event-listener",
740 + "pin-project-lite",
741 + ]
742 +
530 743 [[package]]
531 744 name = "fallible-iterator"
532 745 version = "0.3.0"
@@ -555,6 +768,12 @@
555 768 source = "registry+https://github.com/rust-lang/crates.io-index"
556 769 checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1"
557 770
771 + [[package]]
772 + name = "fastrand"
773 + version = "2.5.0"
774 + source = "registry+https://github.com/rust-lang/crates.io-index"
775 + checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223"
776 +
558 777 [[package]]
559 778 name = "filedescriptor"
560 779 version = "0.8.3"
@@ -602,6 +821,25 @@
602 821 source = "registry+https://github.com/rust-lang/crates.io-index"
603 822 checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d"
604 823
824 + [[package]]
825 + name = "futures-io"
826 + version = "0.3.34"
827 + source = "registry+https://github.com/rust-lang/crates.io-index"
828 + checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed"
829 +
830 + [[package]]
831 + name = "futures-lite"
832 + version = "2.6.1"
833 + source = "registry+https://github.com/rust-lang/crates.io-index"
834 + checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad"
835 + dependencies = [
836 + "fastrand",
837 + "futures-core",
838 + "futures-io",
839 + "parking",
840 + "pin-project-lite",
841 + ]
842 +
605 843 [[package]]
606 844 name = "futures-task"
607 845 version = "0.3.32"
@@ -690,6 +928,12 @@
690 928 source = "registry+https://github.com/rust-lang/crates.io-index"
691 929 checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
692 930
931 + [[package]]
932 + name = "hermit-abi"
933 + version = "0.5.2"
934 + source = "registry+https://github.com/rust-lang/crates.io-index"
935 + checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
936 +
693 937 [[package]]
694 938 name = "hex"
695 939 version = "0.4.3"
@@ -1056,6 +1300,16 @@
1056 1300 "num-traits",
1057 1301 ]
1058 1302
1303 + [[package]]
1304 + name = "ordered-stream"
1305 + version = "0.2.0"
1306 + source = "registry+https://github.com/rust-lang/crates.io-index"
1307 + checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
1308 + dependencies = [
1309 + "futures-core",
1310 + "pin-project-lite",
1311 + ]
1312 +
1059 1313 [[package]]
1060 1314 name = "palette"
1061 1315 version = "0.7.6"
@@ -1080,6 +1334,12 @@
1080 1334 "syn 2.0.118",
1081 1335 ]
1082 1336
1337 + [[package]]
1338 + name = "parking"
1339 + version = "2.2.1"
1340 + source = "registry+https://github.com/rust-lang/crates.io-index"
1341 + checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba"
1342 +
1083 1343 [[package]]
1084 1344 name = "parking_lot"
1085 1345 version = "0.12.5"
@@ -1209,12 +1469,37 @@
1209 1469 source = "registry+https://github.com/rust-lang/crates.io-index"
1210 1470 checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
1211 1471
1472 + [[package]]
1473 + name = "piper"
1474 + version = "0.2.5"
1475 + source = "registry+https://github.com/rust-lang/crates.io-index"
1476 + checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
1477 + dependencies = [
1478 + "atomic-waker",
1479 + "fastrand",
1480 + "futures-io",
1481 + ]
1482 +
1212 1483 [[package]]
1213 1484 name = "pkg-config"
1214 1485 version = "0.3.33"
1215 1486 source = "registry+https://github.com/rust-lang/crates.io-index"
1216 1487 checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e"
1217 1488
1489 + [[package]]
1490 + name = "polling"
1491 + version = "3.11.0"
1492 + source = "registry+https://github.com/rust-lang/crates.io-index"
1493 + checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218"
1494 + dependencies = [
1495 + "cfg-if",
1496 + "concurrent-queue",
1497 + "hermit-abi",
1498 + "pin-project-lite",
1499 + "rustix",
1500 + "windows-sys",
1501 + ]
1502 +
1218 1503 [[package]]
1219 1504 name = "portable-atomic"
1220 1505 version = "1.14.0"
@@ -1227,6 +1512,15 @@
1227 1512 source = "registry+https://github.com/rust-lang/crates.io-index"
1228 1513 checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
1229 1514
1515 + [[package]]
1516 + name = "proc-macro-crate"
1517 + version = "3.5.0"
1518 + source = "registry+https://github.com/rust-lang/crates.io-index"
1519 + checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
1520 + dependencies = [
1521 + "toml_edit",
1522 + ]
1523 +
1230 1524 [[package]]
1231 1525 name = "proc-macro2"
1232 1526 version = "1.0.106"
@@ -1525,6 +1819,17 @@
1525 1819 "zmij",
1526 1820 ]
1527 1821
1822 + [[package]]
1823 + name = "serde_repr"
1824 + version = "0.1.21"
1825 + source = "registry+https://github.com/rust-lang/crates.io-index"
1826 + checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
1827 + dependencies = [
1828 + "proc-macro2",
1829 + "quote",
1830 + "syn 3.0.0",
1831 + ]
1832 +
1528 1833 [[package]]
1529 1834 name = "serde_spanned"
1530 1835 version = "1.1.1"
@@ -1712,6 +2017,19 @@
1712 2017 "unicode-ident",
1713 2018 ]
1714 2019
2020 + [[package]]
2021 + name = "tempfile"
2022 + version = "3.27.0"
2023 + source = "registry+https://github.com/rust-lang/crates.io-index"
2024 + checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
2025 + dependencies = [
2026 + "fastrand",
2027 + "getrandom 0.4.3",
2028 + "once_cell",
2029 + "rustix",
2030 + "windows-sys",
2031 + ]
2032 +
1715 2033 [[package]]
1716 2034 name = "termina"
1717 2035 version = "0.3.3"
@@ -1901,6 +2219,37 @@
1901 2219 source = "registry+https://github.com/rust-lang/crates.io-index"
1902 2220 checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
1903 2221
2222 + [[package]]
2223 + name = "tracing"
2224 + version = "0.1.44"
2225 + source = "registry+https://github.com/rust-lang/crates.io-index"
2226 + checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
2227 + dependencies = [
2228 + "pin-project-lite",
2229 + "tracing-attributes",
2230 + "tracing-core",
2231 + ]
2232 +
2233 + [[package]]
2234 + name = "tracing-attributes"
2235 + version = "0.1.31"
2236 + source = "registry+https://github.com/rust-lang/crates.io-index"
2237 + checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
2238 + dependencies = [
2239 + "proc-macro2",
2240 + "quote",
2241 + "syn 2.0.118",
2242 + ]
2243 +
2244 + [[package]]
2245 + name = "tracing-core"
2246 + version = "0.1.36"
2247 + source = "registry+https://github.com/rust-lang/crates.io-index"
2248 + checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
2249 + dependencies = [
2250 + "once_cell",
2251 + ]
2252 +
1904 2253 [[package]]
1905 2254 name = "typenum"
1906 2255 version = "1.20.1"
@@ -1913,6 +2262,17 @@
1913 2262 source = "registry+https://github.com/rust-lang/crates.io-index"
1914 2263 checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
1915 2264
2265 + [[package]]
2266 + name = "uds_windows"
2267 + version = "1.2.1"
2268 + source = "registry+https://github.com/rust-lang/crates.io-index"
2269 + checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
2270 + dependencies = [
2271 + "memoffset",
2272 + "tempfile",
2273 + "windows-sys",
2274 + ]
2275 +
1916 2276 [[package]]
1917 2277 name = "unicode-ident"
1918 2278 version = "1.0.24"
@@ -1957,6 +2317,7 @@
1957 2317 "atomic",
1958 2318 "getrandom 0.4.3",
1959 2319 "js-sys",
2320 + "serde_core",
1960 2321 "wasm-bindgen",
1961 2322 ]
1962 2323
@@ -2165,31 +2526,150 @@
2165 2526 source = "registry+https://github.com/rust-lang/crates.io-index"
2166 2527 checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59"
2167 2528
2529 + [[package]]
2530 + name = "zbus"
2531 + version = "5.19.0"
2532 + source = "registry+https://github.com/rust-lang/crates.io-index"
2533 + checksum = "5db4be7c075cb421e4b7ee645541604239bd243ba7c357511f4ff3a74b555907"
2534 + dependencies = [
2535 + "async-broadcast",
2536 + "async-executor",
2537 + "async-io",
2538 + "async-lock",
Lines truncated
@@ -1897,6 +1897,39 @@
1897 1897 || { echo "no pkttyagent; the console has no way to answer a polkit prompt" >&2; exit 1; }; \
1898 1898 echo "pkttyagent: present"
1899 1899
1900 + # =====================================================================
1901 + # polkit-agent-helper-1 — the same question for tier 3.
1902 + #
1903 + # The console registers an authentication agent of its own and draws the
1904 + # prompt in Akari (crates/alloy/src/polkit.rs, `Flow::AuthorizeInline`).
1905 + # What it does *not* do is run the PAM conversation: that is this setuid
1906 + # helper's, deliberately, and it is the only reason an agent drawn by us
1907 + # is not an authentication surface written by us.
1908 + #
1909 + # Checked for the same reason and against the same failure as pkttyagent
1910 + # above — it ships inside the polkit package and a base that split it out
1911 + # is silent at build time. Distinct from that check because it is a path
1912 + # rather than a command on `$PATH`, and because the two are used by two
1913 + # different tiers: losing this one leaves tier 2 working and takes away
1914 + # the only way to join a wifi network, which needs a passphrase on stdin
1915 + # that a suspended child has no pipe for.
1916 + #
1917 + # `-u` as well as `-x`: an agent helper that is not setuid root cannot
1918 + # read the shadow file, so it would run, fail every password, and say
1919 + # nothing about why.
1920 + # =====================================================================
1921 + RUN set -eux; \
1922 + helper=; \
1923 + for candidate in /usr/lib/polkit-1/polkit-agent-helper-1 \
1924 + /usr/libexec/polkit-1/polkit-agent-helper-1; do \
1925 + [ -x "$candidate" ] && helper="$candidate" && break; \
1926 + done; \
1927 + [ -n "$helper" ] \
1928 + || { echo "no polkit-agent-helper-1; the console cannot answer polkit without leaving the TUI" >&2; exit 1; }; \
1929 + [ -u "$helper" ] \
1930 + || { echo "$helper is not setuid; every password it is given would fail" >&2; exit 1; }; \
1931 + echo "polkit-agent-helper-1: $helper, setuid"
1932 +
1900 1933 # =====================================================================
1901 1934 # run0 — assert the way to root that Alloy documents is in the image.
1902 1935 #
M docs/CONSOLE.md +16 -3
@@ -143,9 +143,22 @@
143 143 `alloy net` writes as well as reads: connect, disconnect, and the wifi radio. Those three
144 144 need no privilege at all, which is a fact rather than a design: NetworkManager's shipped
145 145 policy grants `network-control` and `enable-disable-wifi` to an active session outright.
146 - Joining a *new* network is `settings.modify.system` and does need authorization, so it
147 - waits on the ladder in wiki note `alloy-privilege`. Nothing in the console escalates
148 - anything today.
146 +
147 + Joining a *new* network is `settings.modify.system`, which the shipped rule deliberately
148 + does not grant, so it needs an answer every time. The screen asks three questions in three
149 + modes — which device, which network in range, what is the passphrase — and Esc walks back
150 + through them. The passphrase is never an argument: `nmcli --ask` prompts for it and reads
151 + the prompt from stdin, so it does not appear in `ps` for every user on the machine the way
152 + `device wifi connect SSID password PW` would.
153 +
154 + That one action is what forced tier 3 of wiki `alloy-privilege`, because tier 2 cannot
155 + reach it. Tier 2 answers polkit by suspending the console and running the command under
156 + `pkttyagent`; a suspended child inherits the terminal's stdio, so there is no pipe left to
157 + carry the passphrase on, and a secret and a suspend cannot both be had. So the join runs
158 + beside the event loop with an authentication agent the console registers for its own
159 + process, and polkit's question arrives as a modal in Akari naming the action. The PAM
160 + conversation is still polkit's own setuid helper — Alloy supplies the cookie, draws the
161 + prompt, and hands back what was typed, and decides nothing about whether it was right.
149 162
150 163 `alloy disk` is the one pane that rewrites a partition table, and the only place
151 164 in the console where a keypress can destroy data that was not already being
@@ -45,5 +45,16 @@
45 45 # the crate.
46 46 rusqlite = { version = "0.40", features = ["bundled"] }
47 47
48 + # The system bus, for the polkit authentication agent in `polkit.rs` (tier 3 of
49 + # wiki `alloy-privilege`). Pure Rust rather than FFI into libpolkit-agent-1,
50 + # which is GLib: the console's whole position is that it links no toolkit, and
51 + # a D-Bus client is the one part of an agent that is genuinely mechanical.
52 + #
53 + # `blocking` because the console is a synchronous event loop and always will be.
54 + # The feature still starts an executor thread of its own inside zbus, which is
55 + # where the agent's callbacks are served from; what it avoids is an async colour
56 + # spreading through a TUI that has one thread and one frame at a time.
57 + zbus = { version = "5", default-features = false, features = ["blocking-api", "async-io"] }
58 +
48 59 [lints]
49 60 workspace = true
@@ -19,6 +19,7 @@
19 19 mod monitors;
20 20 mod net;
21 21 mod pkg;
22 + mod polkit;
22 23 mod profile;
23 24 mod recovery;
24 25 mod run;
@@ -585,6 +585,13 @@
585 585 /// does.
586 586 wifi: Option<bool>,
587 587 mode: Mode,
588 + /// The network a join is in flight for, so its outcome can be reported
589 + /// against a name.
590 + ///
591 + /// Held here rather than read back out of [`Mode`] when the answer arrives,
592 + /// because the mode has moved on by then: the passphrase is gone the moment
593 + /// it becomes a [`Secret`], which is the point.
594 + pending: Option<String>,
588 595 }
589 596
590 597 impl NetView {
@@ -596,6 +603,7 @@
596 603 error: None,
597 604 wifi: None,
598 605 mode: Mode::Devices,
606 + pending: None,
599 607 };
600 608 view.refresh(log);
601 609 view
@@ -639,77 +647,59 @@
639 647 }
640 648
641 649 /// Act on the selected network: join an open one, ask about a secured one.
642 - fn choose(&mut self, log: &mut CommandLog) {
650 + fn choose(&mut self) -> Flow {
643 651 let Mode::Networks { networks, cursor } = &self.mode else {
644 - return;
652 + return Flow::Continue;
645 653 };
646 654 let Some(network) = cursor.selected().and_then(|index| networks.get(index)) else {
647 - return;
655 + return Flow::Continue;
648 656 };
649 657
650 658 if network.security.is_none() {
651 659 let ssid = network.ssid.clone();
652 - self.join(&ssid, None, log);
653 - return;
660 + return self.join(&ssid, None);
654 661 }
655 662 self.mode = Mode::Passphrase {
656 663 ssid: network.ssid.clone(),
657 664 field: TextField::new(),
658 665 };
666 + Flow::Continue
659 667 }
660 668
661 669 /// Join with what has been typed.
662 - fn submit(&mut self, log: &mut CommandLog) {
663 - let Mode::Passphrase { ssid, field } = &self.mode else {
664 - return;
670 + ///
671 + /// The passphrase leaves the [`TextField`] here and does not go back: the
672 + /// field is emptied in the same breath as the [`Secret`] is built, so a
673 + /// prompt that fails and is asked again starts from nothing rather than
674 + /// from a value still sitting in a widget.
675 + fn submit(&mut self) -> Flow {
676 + let Mode::Passphrase { ssid, field } = &mut self.mode else {
677 + return Flow::Continue;
665 678 };
666 679 let ssid = ssid.clone();
667 680 let secret = Secret::new(field.value().as_bytes().to_vec());
668 - self.join(&ssid, Some(secret), log);
681 + field.set("");
682 + self.join(&ssid, Some(secret))
669 683 }
670 684
671 - /// Run the join, and say what happened.
685 + /// Hand the join to the shell, which runs it with an agent to answer polkit.
672 686 ///
673 - /// Success returns to the device list, because that is where the answer is:
674 - /// the interface the user was looking at now says `connected` and names the
675 - /// network. Staying on the scan would mean reporting the outcome in a
676 - /// sentence beside a list that has not changed.
677 - fn join(&mut self, ssid: &str, passphrase: Option<Secret>, log: &mut CommandLog) {
687 + /// [`Flow::AuthorizeInline`] rather than running it here, and this screen is
688 + /// the reason that flow exists. Joining a network NM has not saved is
689 + /// `settings.modify.system`, which `50-alloy-settings.rules` deliberately
690 + /// does not grant ("saving a new connection is a real administrative act"),
691 + /// so it always wants an answer. Tier 2 cannot give one: it suspends, a
692 + /// suspended child inherits the terminal's stdio, and there is then no pipe
693 + /// for the passphrase. So the command runs beside the event loop with the
694 + /// console's own polkit agent registered, and both questions — polkit's and
695 + /// the passphrase — are asked on screen.
696 + fn join(&mut self, ssid: &str, passphrase: Option<Secret>) -> Flow {
678 697 let Some(invocation) = self.backend.join(ssid, passphrase) else {
679 698 self.error = Some(format!("{} cannot join a network", self.backend.name()));
680 - return;
699 + return Flow::Continue;
681 700 };
682 -
683 - // stdout is dropped rather than reported, and that is not tidiness.
684 - // Fed a pipe, nmcli's prompt echoes what it reads, so the passphrase
685 - // can be in the output of a command that carried it privately in every
686 - // other respect. `capture` reports failures out of stderr, which the
687 - // echo does not reach, so nothing that surfaces has been near it.
688 - match invocation.run(log).map(drop) {
689 - Ok(()) => {
690 - self.mode = Mode::Devices;
691 - self.error = None;
692 - log.quiet(|log| self.refresh(log));
693 - }
694 - Err(err) => {
695 - // Tier 2 of wiki `alloy-privilege` cannot help here, and this
696 - // is the one place in the console where that is true. Joining a
697 - // network NM has not saved is `settings.modify.system`, which
698 - // `50-alloy-settings.rules` deliberately does not grant, so it
699 - // always wants an answer; and `Flow::Authorize` reaches it by
700 - // suspending the TUI and handing the command to `pkttyagent`,
701 - // which inherits stdio and so has no pipe to carry the
702 - // passphrase on. A secret and a suspend cannot both be had.
703 - // That is what tier 3 is for, and until it lands the honest
704 - // thing is to name the wall rather than to suspend into a
705 - // command that would arrive without its passphrase.
706 - self.error = Some(if crate::cli::wants_authentication(&err) {
707 - format!("joining {ssid} needs authorization this console cannot ask for yet")
708 - } else {
709 - err.to_string()
710 - });
711 - }
712 - }
701 + self.pending = Some(ssid.to_string());
702 + Flow::AuthorizeInline(invocation)
713 703 }
714 704
715 705 /// Whether the selected interface has a connect action behind it, which on
@@ -976,6 +966,47 @@
976 966 (self.wifi == Some(false)).then(|| (Severity::Warn, "wifi radio off".to_string()))
977 967 }
978 968
969 + /// The join finished, one way or the other.
970 + ///
971 + /// Success returns to the device list, because that is where the answer is:
972 + /// the interface the user was looking at now says `connected` and names the
973 + /// network. Staying on the scan would mean reporting the outcome in a
974 + /// sentence beside a list that has not changed.
975 + fn authorized(&mut self, outcome: Result<String>, log: &mut CommandLog) {
976 + // Named "the network" rather than left blank when there is no name to
977 + // hand. Nothing reaches this without a join in flight, and a sentence
978 + // with a hole in it is what that assumption looks like when it stops
979 + // being true.
980 + let ssid = self
981 + .pending
982 + .take()
983 + .unwrap_or_else(|| "the network".to_string());
984 + match outcome {
985 + // The output is dropped rather than reported, and that is not
986 + // tidiness. Fed a pipe, nmcli's `--ask` prompt echoes what it reads,
987 + // so the passphrase can be in the stdout of a command that carried
988 + // it privately in every other respect. Failures are reported out of
989 + // stderr, which the echo does not reach.
990 + Ok(_) => {
991 + self.mode = Mode::Devices;
992 + self.error = None;
993 + log.quiet(|log| self.refresh(log));
994 + }
995 + Err(err) => {
996 + // Reaching this with an authentication message means the agent
997 + // could not be registered or could not answer — the fallback
998 + // path in `shell::authorize_inline`, which says so in the log
999 + // pane. Worth its own sentence, because "not authorized" and
1000 + // "nobody could be asked" send someone to different places.
1001 + self.error = Some(if crate::cli::wants_authentication(&err) {
1002 + format!("joining {ssid} was not authorized")
1003 + } else {
1004 + err.to_string()
1005 + });
1006 + }
1007 + }
1008 + }
1009 +
979 1010 /// True while the passphrase field is open, so `q` and `?` are letters.
980 1011 ///
981 1012 /// [`View::text_entry`]'s obligation, and this is the first shipped view to
@@ -1070,7 +1101,7 @@
1070 1101 Mode::Networks { cursor, .. } => match key.code {
1071 1102 KeyCode::Char('j') | KeyCode::Down => cursor.next(),
1072 1103 KeyCode::Char('k') | KeyCode::Up => cursor.prev(),
1073 - KeyCode::Enter => self.choose(log),
1104 + KeyCode::Enter => return self.choose(),
1074 1105 KeyCode::Char('n') => self.scan(log),
1075 1106 _ => {}
1076 1107 },
@@ -1084,7 +1115,7 @@
1084 1115 KeyCode::Right => field.right(),
1085 1116 KeyCode::Home => field.home(),
1086 1117 KeyCode::End => field.end(),
1087 - KeyCode::Enter => self.submit(log),
1118 + KeyCode::Enter => return self.submit(),
1088 1119 _ => {}
1089 1120 },
1090 1121 }
@@ -1220,6 +1251,7 @@
1220 1251 error: None,
1221 1252 wifi: None,
1222 1253 mode: Mode::Devices,
1254 + pending: None,
1223 1255 };
1224 1256 view.refresh(&mut log);
1225 1257 (view, log)
@@ -1530,6 +1562,7 @@
1530 1562 error: None,
1531 1563 wifi: None,
1532 1564 mode: Mode::Devices,
1565 + pending: None,
1533 1566 };
1534 1567 view.refresh(&mut log);
1535 1568 (view, log)
@@ -1553,7 +1586,7 @@
1553 1586 fn a_secured_network_asks_for_a_passphrase_first() {
1554 1587 let (mut view, mut log) = joinable_view();
1555 1588 view.scan(&mut log);
1556 - view.choose(&mut log);
1589 + view.choose();
1557 1590 match &view.mode {
1558 1591 Mode::Passphrase { ssid, field } => {
1559 1592 assert_eq!(ssid, "Example Network");
@@ -1571,26 +1604,73 @@
1571 1604 if let Mode::Networks { cursor, .. } = &mut view.mode {
1572 1605 cursor.move_by(2);
1573 1606 }
1574 - view.choose(&mut log);
1575 - assert!(
1576 - matches!(view.mode, Mode::Devices),
1577 - "a completed join goes back to the devices, {:?}",
1578 - view.mode,
1579 - );
1607 + match view.choose() {
1608 + Flow::AuthorizeInline(invocation) => {
1609 + assert_eq!(invocation.display(), "true 'Airport WiFi'");
1610 + }
1611 + other => panic!("expected the join to be raised, got {other:?}"),
1612 + }
1613 + assert_eq!(view.pending.as_deref(), Some("Airport WiFi"));
1614 + }
1615 +
1616 + // The shell runs the join and hands the answer back. Success lands on the
1617 + // devices, because that is where the evidence is: the interface now says
1618 + // connected and names the network.
1619 + #[test]
1620 + fn a_join_that_worked_returns_to_the_devices() {
1621 + let (mut view, mut log) = joinable_view();
1622 + view.scan(&mut log);
1623 + view.choose();
1624 + view.submit();
1625 + view.authorized(Ok(String::new()), &mut log);
1626 + assert!(matches!(view.mode, Mode::Devices), "{:?}", view.mode);
1580 1627 assert_eq!(view.error, None);
1581 1628 }
1582 1629
1630 + // "not authorized" and "nobody could be asked" send someone to different
1631 + // places, so they are not the same sentence.
1632 + #[test]
1633 + fn a_refused_join_says_it_was_refused_and_names_the_network() {
1634 + let (mut view, mut log) = joinable_view();
1635 + view.scan(&mut log);
1636 + view.choose();
1637 + view.submit();
1638 + view.authorized(Err(anyhow::anyhow!(crate::cli::INTERACTIVE_AUTH)), &mut log);
1639 + let message = view.error.as_ref().expect("something was said");
1640 + assert!(message.contains("Example Network"), "{message}");
1641 + assert!(message.contains("not authorized"), "{message}");
1642 + }
1643 +
1644 + #[test]
1645 + fn any_other_failure_is_reported_as_nmcli_worded_it() {
1646 + let (mut view, mut log) = joinable_view();
1647 + view.scan(&mut log);
1648 + view.choose();
1649 + view.submit();
1650 + view.authorized(Err(anyhow::anyhow!("No network with SSID found")), &mut log);
1651 + assert_eq!(view.error.as_deref(), Some("No network with SSID found"),);
1652 + }
1653 +
1583 1654 // The pane's whole promise is that it shows what ran. This is the one value
1584 1655 // it must show having run *without* showing what it was.
1585 1656 #[test]
1586 1657 fn the_passphrase_never_reaches_the_log_pane() {
1587 1658 let (mut view, mut log) = joinable_view();
1588 1659 view.scan(&mut log);
1589 - view.choose(&mut log);
1660 + view.choose();
1590 1661 for c in "hunter2-and-a-half".chars() {
1591 1662 view.handle(KeyEvent::from(KeyCode::Char(c)), &mut log);
1592 1663 }
1593 - view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
1664 +
1665 + // What the shell will show, since it is the shell that logs an
1666 + // authorized command: the argv, and a note that something was piped in.
1667 + let raised = view.handle(KeyEvent::from(KeyCode::Enter), &mut log);
1668 + let Flow::AuthorizeInline(invocation) = raised else {
1669 + panic!("expected the join to be raised, got {raised:?}");
1670 + };
1671 + let shown = invocation.display();
1672 + assert!(!shown.contains("hunter2"), "{shown}");
1673 + assert!(shown.contains("input withheld"), "{shown}");
1594 1674
1595 1675 let transcript: String = log
1596 1676 .entries()
@@ -1599,10 +1679,23 @@
1599 1679 .collect::<Vec<_>>()
1600 1680 .join("\n");
1601 1681 assert!(!transcript.contains("hunter2"), "{transcript}");
1602 - assert!(
1603 - transcript.contains("input withheld"),
1604 - "the pane still says something was piped in: {transcript}",
1605 - );
1682 + }
1683 +
1684 + // The field is emptied as the secret is built, so a second prompt after a
1685 + // refusal starts from nothing rather than from what is still in the widget.
1686 + #[test]
1687 + fn submitting_empties_the_field_it_read() {
1688 + let (mut view, mut log) = joinable_view();
1689 + view.scan(&mut log);
1690 + view.choose();
1691 + for c in "hunter2".chars() {
1692 + view.handle(KeyEvent::from(KeyCode::Char(c)), &mut log);
1693 + }
1694 + view.submit();
1695 + match &view.mode {
1696 + Mode::Passphrase { field, .. } => assert_eq!(field.value(), ""),
1697 + other => panic!("still asking, {other:?}"),
1698 + }
1606 1699 }
1607 1700
1608 1701 // `q` and `?` are letters while a passphrase is open. Without this the
@@ -1613,7 +1706,7 @@
1613 1706 assert!(!view.text_entry(), "the device list does not");
1614 1707 view.scan(&mut log);
1615 1708 assert!(!view.text_entry(), "nor does the network list");
1616 - view.choose(&mut log);
1709 + view.choose();
1617 1710 assert!(view.text_entry());
1618 1711 }
1619 1712
@@ -1627,7 +1720,7 @@
1627 1720 assert!(matches!(view.mode, Mode::Devices));
1628 1721
1629 1722 view.scan(&mut log);
1630 - view.choose(&mut log);
1723 + view.choose();
1631 1724 assert!(matches!(view.cancel(), Flow::Continue));
1632 1725 assert!(
1633 1726 matches!(view.mode, Mode::Devices),
@@ -16,7 +16,7 @@
16 16 };
17 17 use anyhow::{Context, Result};
18 18 use ratatui::Frame;
19 - use ratatui::crossterm::event::{self, Event, KeyEvent, KeyEventKind};
19 + use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind};
20 20 use ratatui::layout::Rect;
21 21
22 22 use crate::cli::CommandLog;
@@ -55,6 +55,25 @@
55 55 /// not the seamless one: the screen tears down, polkit's own text prompt
56 56 /// appears in the terminal's colors, and the console rebuilds after.
57 57 Authorize(Command),
58 + /// Run a command that polkit will ask about, and answer it in the console.
59 + ///
60 + /// Tier 3. The TUI stays up: an agent of the console's own
61 + /// ([`polkit`](crate::polkit)) is registered for this process, the command
62 + /// runs on a thread beside the event loop, and polkit's question arrives as
63 + /// a modal in Akari naming the action.
64 + ///
65 + /// Takes an [`Invocation`] rather than a [`Command`], which is the
66 + /// difference that forced this tier to exist rather than merely improving
67 + /// on tier 2. `Authorize` suspends, and a suspended child inherits the
68 + /// terminal's stdio, so it has no pipe: an invocation carrying a
69 + /// [`Secret`](crate::cli::Secret) cannot go that way at all. Joining a wifi
70 + /// network is exactly that shape — a passphrase on stdin *and* a polkit
71 + /// action — and it is what `net.rs` raises this for.
72 + ///
73 + /// The outcome goes back to the view through [`View::authorized`] rather
74 + /// than out of the event loop. A wrong password is an ordinary answer to a
75 + /// question the user was asked, not a reason to close the console.
76 + AuthorizeInline(crate::cli::Invocation),
58 77 }
59 78
60 79 /// A confirmation prompt raised by a view.
@@ -191,6 +210,20 @@
191 210 /// belongs inside [`CommandLog::quiet`].
192 211 fn tick(&mut self, _log: &mut CommandLog) {}
193 212
213 + /// The command raised with [`Flow::AuthorizeInline`] has finished.
214 + ///
215 + /// Carries what the command said, which is the point: a view that asked for
216 + /// a network to be joined needs to know whether it was, and the failure it
217 + /// most wants to report — a passphrase polkit or NM did not accept — is an
218 + /// ordinary nonzero exit rather than anything the shell should treat as
219 + /// exceptional.
220 + ///
221 + /// stdout arrives as an `Ok` payload nobody is obliged to read, and one
222 + /// caller deliberately drops it: nmcli's `--ask` prompt echoes what it
223 + /// reads from a pipe, so the output of a command carrying a passphrase can
224 + /// contain it. Default is nothing.
225 + fn authorized(&mut self, _outcome: Result<String>, _log: &mut CommandLog) {}
226 +
194 227 /// The console has just taken the terminal back from a child.
195 228 ///
196 229 /// Raised after [`Flow::Suspend`] and [`Flow::Authorize`], where the child
@@ -298,6 +331,9 @@
298 331 Flow::Confirm(raised) => modal = Some(raised),
299 332 Flow::Suspend(command) => suspend(terminal, view, log, command)?,
300 333 Flow::Authorize(command) => authorize(terminal, view, log, command)?,
334 + Flow::AuthorizeInline(invocation) => {
335 + authorize_inline(terminal, theme, view, log, invocation)?;
336 + }
301 337 Flow::Continue => {}
302 338 }
303 339 }
@@ -339,6 +375,9 @@
339 375 Flow::Confirm(confirm) => modal = Some(confirm),
340 376 Flow::Suspend(command) => suspend(terminal, view, log, command)?,
341 377 Flow::Authorize(command) => authorize(terminal, view, log, command)?,
378 + Flow::AuthorizeInline(invocation) => {
379 + authorize_inline(terminal, theme, view, log, invocation)?;
380 + }
342 381 }
343 382 }
344 383 }
@@ -542,6 +581,151 @@
542 581 outcome
543 582 }
544 583
584 + /// How often the authorization loop wakes to check on the command and the
585 + /// agent.
586 + ///
587 + /// Short, unlike [`TICK`], and for a reason the main loop does not have: there
588 + /// is a person waiting at a prompt, and the two events this is watching for —
589 + /// polkit asking a question, and the command finishing — arrive on other
590 + /// threads and cannot wake the poll themselves. A tenth of a second is under
591 + /// what anyone perceives and costs a few wakeups per second for the seconds
592 + /// this loop is alive.
593 + const AUTHORIZATION_POLL: Duration = Duration::from_millis(100);
594 +
595 + /// Run `invocation` with the console's own polkit agent registered, drawing
596 + /// polkit's question as a modal without leaving the TUI.
597 + ///
598 + /// Three things happen at once here, which is why this is a loop of its own
599 + /// rather than a call: the command runs on a worker thread, zbus serves the
600 + /// agent on another, and this thread keeps drawing. The alternative is running
601 + /// the command inline, which deadlocks on the first prompt — the agent's
602 + /// question would arrive at a thread that is blocked waiting for the command
603 + /// that is waiting for the answer.
604 + ///
605 + /// The view is drawn underneath throughout and its keys are inert while the
606 + /// modal is up, which is the modal rule from the main loop. What is not from
607 + /// the main loop is that the *view* is frozen too: it has a command in flight
608 + /// that it asked for, and letting `j` move a selection under a prompt naming a
609 + /// row is how a confirm ends up applying to something else.
610 + ///
611 + /// Registration failing is not fatal and not silent. The command runs anyway,
612 + /// polkit finds no agent, and it comes back with the same "interactive
613 + /// authentication required" the console would have reported before this
614 + /// existed — so the fallback is the previous behaviour rather than a new
615 + /// failure, and the pane says which of the two happened.
616 + fn authorize_inline(
617 + terminal: &mut ratatui::DefaultTerminal,
618 + theme: &Theme,
619 + view: &mut dyn View,
620 + log: &mut CommandLog,
621 + invocation: crate::cli::Invocation,
622 + ) -> Result<()> {
623 + let display = invocation.display();
624 +
625 + let agent = match crate::polkit::Agent::register() {
626 + Ok(agent) => Some(agent),
627 + Err(error) => {
628 + log.record(
629 + format!("# no authentication agent: {error}"),
630 + Severity::Warn,
631 + );
632 + None
633 + }
634 + };
635 +
636 + // Moved to a worker rather than run here, and it takes its `Secret` with
637 + // it. `capture_quiet` because this thread has the log and the worker does
638 + // not; the invocation is recorded below, once its outcome is known.
639 + let worker = std::thread::spawn(move || invocation.capture_quiet());
640 +
641 + let mut prompt: Option<(crate::polkit::Prompt, alloy_tui::TextField)> = None;
642 +
643 + let outcome = loop {
644 + // Rebuilt every frame from the prompt and what has been typed, which is
645 + // what lets a `Confirm` — a value with no input in it — carry a text
646 + // field. The modal's own footer already reads `Enter confirm Esc
647 + // cancel`, which is exactly what the two keys do here.
648 + let modal = prompt.as_ref().map(|(prompt, field)| Confirm {
649 + title: prompt.message.clone(),
650 + message: format!(
651 + "{} {}",
652 + prompt.question,
653 + if prompt.echo {
654 + field.value().to_string()
655 + } else {
656 + "•".repeat(field.value().chars().count())
657 + }
658 + ),
659 + severity: Severity::Warn,
660 + });
661 + terminal.draw(|frame| draw(frame, theme, view, log, modal.as_ref(), false))?;
662 +
663 + if prompt.is_none()
664 + && let Some(agent) = &agent
665 + && let Some(pending) = agent.pending()
666 + {
667 + // Recorded as commentary, the way the mock backends mark a line
668 + // that is not a command. Naming the action is the one thing every
669 + // good version of this prompt does — it is what Omarchy's Quattro
670 + // PR added to theirs (wiki `alloy-privilege`) — and the pane is
671 + // where the console says what is happening.
672 + log.record(
673 + format!("# polkit asks about {}", pending.action_id),
674 + Severity::Warn,
675 + );
676 + prompt = Some((pending, alloy_tui::TextField::new()));
677 + }
678 +
679 + if event::poll(AUTHORIZATION_POLL)?
680 + && let Event::Key(key) = event::read()?
681 + && key.kind == KeyEventKind::Press
682 + && let Some((asked, field)) = prompt.take()
683 + {
684 + match key.code {
685 + KeyCode::Enter => asked.answer(field.value().to_string()),
686 + KeyCode::Esc => asked.dismiss(),
687 + code => {
688 + // Not taken after all: put it back with the key applied.
689 + let mut field = field;
690 + match code {
691 + KeyCode::Char(c) => field.insert(c),
692 + KeyCode::Backspace => field.backspace(),
693 + KeyCode::Delete => field.delete(),
694 + KeyCode::Left => field.left(),
695 + KeyCode::Right => field.right(),
696 + KeyCode::Home => field.home(),
697 + KeyCode::End => field.end(),
698 + _ => {}
699 + }
700 + prompt = Some((asked, field));
701 + }
702 + }
703 + }
704 +
705 + if worker.is_finished() {
706 + break worker
707 + .join()
708 + .unwrap_or_else(|_| Err(anyhow::anyhow!("the command's thread panicked")));
709 + }
710 + };
711 +
712 + // Answered or abandoned, the question is over: an agent outliving the
713 + // command it was registered for would answer for the next thing to ask.
714 + drop(prompt);
715 + drop(agent);
716 +
717 + log.record(
718 + display,
719 + if outcome.is_ok() {
720 + Severity::Healthy
721 + } else {
722 + Severity::Error
723 + },
724 + );
725 + view.authorized(outcome, log);
726 + Ok(())
727 + }
728 +
545 729 fn draw(
546 730 frame: &mut Frame,
547 731 theme: &Theme,
@@ -1,0 +1,712 @@
1 + //! Tier 3 of wiki `alloy-privilege`: an authentication agent the console draws
2 + //! itself.
3 + //!
4 + //! Tier 2 ([`Flow::Authorize`](crate::shell::Flow::Authorize)) answers polkit by
5 + //! tearing the TUI down and letting `pkttyagent` prompt on the bare terminal. It
6 + //! is honest and it is jarring, and for one action it is not merely jarring but
7 + //! impossible: joining a wifi network needs a passphrase piped to the child, and
8 + //! a suspended child inherits the terminal's stdio, so there is no pipe. A
9 + //! secret and a suspend cannot both be had (`net.rs`, `NetView::join`).
10 + //!
11 + //! So this speaks polkit's agent protocol directly. The shape is documented
12 + //! rather than reverse-engineered:
13 + //!
14 + //! 1. Register with `Authority.RegisterAuthenticationAgent`, naming a **subject**
15 + //! (this process), a locale, and the object path we serve.
16 + //! 2. polkit calls `AuthenticationAgent.BeginAuthentication` on that object when
17 + //! something the console ran needs an answer. The call does not return until
18 + //! the authentication is over: returning normally means it succeeded, and
19 + //! returning an error means it did not.
20 + //! 3. The PAM conversation itself is **not ours**. It runs in the setuid
21 + //! `polkit-agent-helper-1`, whose protocol is plain text on stdin and stdout.
22 + //! Alloy supplies the cookie, relays prompts to the screen, and hands back
23 + //! what was typed. Nothing here talks to PAM, hashes anything, or decides
24 + //! whether an answer was right.
25 + //!
26 + //! **What is Alloy's here is the drawing, and deliberately only the drawing.**
27 + //! That is the whole of the wedge from wiki `alloy-privilege`: the prompt is an
28 + //! Akari modal naming the action, in the console, instead of a torn-down screen
29 + //! and a block of text in whatever colors the terminal happens to have. Every
30 + //! decision about whether the answer is acceptable stays inside polkit's own
31 + //! audited code, which is the only version of this worth shipping.
32 + //!
33 + //! Registration is scoped to this **process**, the way `pkttyagent --process`
34 + //! does, and not to the session. A session-scoped agent would fight whatever
35 + //! the desktop already registered and would answer for programs the console
36 + //! never ran; a process-scoped one covers exactly the command the console is
37 + //! about to run and the children it spawns.
38 +
39 + use std::collections::HashMap;
40 + use std::io::{BufRead, BufReader, Write};
41 + use std::path::{Path, PathBuf};
42 + use std::process::{Child, Command, Stdio};
43 + use std::sync::mpsc::{Receiver, SyncSender, sync_channel};
44 +
45 + use anyhow::{Context, Result, anyhow, bail};
46 + use zbus::blocking::Connection;
47 + use zbus::interface;
48 + use zbus::zvariant::{OwnedValue, Value};
49 +
50 + /// Where polkit keeps the setuid helper that runs the PAM conversation.
51 + ///
52 + /// Both paths are tried in order. Fedora (and so Alloy) ships `/usr/lib`;
53 + /// Debian-family systems use `/usr/libexec`, which is what a development box
54 + /// running the console outside an image is likely to have. Neither is a
55 + /// configuration knob: a helper somewhere else is a polkit nobody here knows,
56 + /// and guessing further would mean executing an arbitrary path as part of an
57 + /// authentication.
58 + const HELPERS: [&str; 2] = [
59 + "/usr/lib/polkit-1/polkit-agent-helper-1",
60 + "/usr/libexec/polkit-1/polkit-agent-helper-1",
61 + ];
62 +
63 + /// The object path this agent serves. Arbitrary, and only has to be ours.
64 + const AGENT_PATH: &str = "/work/makenot/alloy/PolkitAgent";
65 +
66 + const AUTHORITY_SERVICE: &str = "org.freedesktop.PolicyKit1";
67 + const AUTHORITY_PATH: &str = "/org/freedesktop/PolicyKit1/Authority";
68 + const AUTHORITY_INTERFACE: &str = "org.freedesktop.PolicyKit1.Authority";
69 +
70 + /// A question the agent needs the screen to ask.
71 + ///
72 + /// Carries its own reply channel rather than an id the caller has to match up:
73 + /// the D-Bus thread is blocked inside `BeginAuthentication` waiting on exactly
74 + /// this one, so a request that cannot be answered is a request nobody should be
75 + /// able to construct.
76 + pub(crate) struct Prompt {
77 + /// The polkit action, e.g. `org.freedesktop.NetworkManager.settings.modify.system`.
78 + pub action_id: String,
79 + /// polkit's own human-readable line for the action.
80 + pub message: String,
81 + /// What the helper asked for, usually `Password: `.
82 + pub question: String,
83 + /// Whether what is typed should be drawn. False for a password, true for
84 + /// the one-time-code shape (`PAM_PROMPT_ECHO_ON`), which does exist and
85 + /// which a masked field would make unusable.
86 + pub echo: bool,
87 + reply: SyncSender<Option<String>>,
88 + }
89 +
90 + impl Prompt {
91 + /// Answer the prompt.
92 + ///
93 + /// The answer is a `String` and not a [`Secret`](crate::cli::Secret),
94 + /// because the helper reads it as a line of text and there is nowhere else
95 + /// for it to go: it crosses one channel, is written to one pipe, and is
96 + /// dropped. What a `Secret` buys elsewhere is a scrubbed buffer inside a
97 + /// long-lived [`Invocation`](crate::cli::Invocation); nothing here is
98 + /// long-lived.
99 + pub(crate) fn answer(self, response: String) {
100 + // A send that fails means the D-Bus thread gave up first — polkit
101 + // cancelled, or the command died. Nothing to report: the screen is
102 + // about to be told the same thing by the outcome channel.
103 + let _ = self.reply.send(Some(response));
104 + }
105 +
106 + /// Refuse it. polkit is told the authentication failed.
107 + pub(crate) fn dismiss(self) {
108 + let _ = self.reply.send(None);
109 + }
110 + }
111 +
112 + /// A registered authentication agent.
113 + ///
114 + /// Unregisters on drop, which is not tidiness: an agent left registered keeps
115 + /// answering for a console that has stopped asking, and the next thing to want
116 + /// authorization would block on a prompt nobody is drawing.
117 + pub(crate) struct Agent {
118 + connection: Connection,
119 + subject: Subject,
120 + prompts: Receiver<Prompt>,
121 + }
122 +
123 + impl Agent {
124 + /// Register an agent for this process.
125 + ///
126 + /// Fails rather than degrading when there is no system bus or polkit does
127 + /// not answer. The caller's fallback is tier 2, which is a decision about
128 + /// what the console does next rather than something to paper over here.
129 + pub(crate) fn register() -> Result<Self> {
130 + let helper = helper_path().context("no polkit-agent-helper-1 to run the conversation")?;
131 + let subject = Subject::this_process()?;
132 + let (sender, prompts) = sync_channel(0);
133 +
134 + let connection = zbus::blocking::connection::Builder::system()
135 + .context("no system bus")?
136 + .serve_at(AGENT_PATH, Listener { helper, sender })
137 + .context("the agent object path is already in use")?
138 + .build()
139 + .context("the agent could not be served")?;
140 +
141 + connection
142 + .call_method(
143 + Some(AUTHORITY_SERVICE),
144 + AUTHORITY_PATH,
145 + Some(AUTHORITY_INTERFACE),
146 + "RegisterAuthenticationAgent",
147 + // The path is a plain string here, not an `o`. polkit's own
148 + // signature is `((sa{sv})ss)` and it rejects an object path
149 + // outright, which is worth stating: every other D-Bus API in
150 + // sight takes the typed form, and the mismatch is a registration
151 + // that fails at runtime with nothing at compile time to catch it.
152 + &(subject.as_tuple(), locale(), AGENT_PATH),
153 + )
154 + .context("polkit refused the agent registration")?;
155 +
156 + Ok(Self {
157 + connection,
158 + subject,
159 + prompts,
160 + })
161 + }
162 +
163 + /// The question waiting to be drawn, if there is one.
164 + ///
165 + /// Non-blocking, because the caller is an event loop that also has a frame
166 + /// to draw and keys to read. Polling rather than a callback for the same
167 + /// reason every other console refresh is a poll: there is one thread that
168 + /// owns the screen and it is not this one.
169 + pub(crate) fn pending(&self) -> Option<Prompt> {
170 + self.prompts.try_recv().ok()
171 + }
172 + }
173 +
174 + impl Drop for Agent {
175 + fn drop(&mut self) {
176 + // Best effort by necessity: a drop cannot report, and the failure modes
177 + // are a bus that has gone away and a polkit that has restarted, in both
178 + // of which the registration is already void.
179 + let _ = self.connection.call_method(
180 + Some(AUTHORITY_SERVICE),
181 + AUTHORITY_PATH,
182 + Some(AUTHORITY_INTERFACE),
183 + "UnregisterAuthenticationAgent",
184 + &(self.subject.as_tuple(), AGENT_PATH),
185 + );
186 + }
187 + }
188 +
189 + /// The polkit subject this agent covers: one process, pinned by start time.
190 + ///
191 + /// The start time is not decoration. A pid is reused, and an agent registered
192 + /// for pid 4021 would otherwise still be registered for whatever takes that pid
193 + /// next; polkit pairs the two so a subject names one run of one process.
194 + #[derive(Debug, Clone, PartialEq, Eq)]
195 + struct Subject {
196 + pid: u32,
197 + start_time: u64,
198 + }
199 +
200 + impl Subject {
201 + fn this_process() -> Result<Self> {
202 + let pid = std::process::id();
203 + let stat = std::fs::read_to_string("/proc/self/stat").context("no /proc/self/stat")?;
204 + Ok(Self {
205 + pid,
206 + start_time: start_time(&stat).context("no start time in /proc/self/stat")?,
207 + })
208 + }
209 +
210 + /// The `(sa{sv})` polkit takes: a kind, and details keyed by name.
211 + fn as_tuple(&self) -> (String, HashMap<String, OwnedValue>) {
212 + let mut details: HashMap<String, OwnedValue> = HashMap::new();
213 + details.insert("pid".into(), Value::from(self.pid).try_into().expect("u32"));
214 + details.insert(
215 + "start-time".into(),
216 + Value::from(self.start_time).try_into().expect("u64"),
217 + );
218 + ("unix-process".into(), details)
219 + }
220 + }
221 +
222 + /// Field 22 of `/proc/<pid>/stat`, the process start time in clock ticks.
223 + ///
224 + /// Parsed from the **last** `)` rather than by splitting on whitespace from the
225 + /// left. Field 2 is the executable name in parentheses and it can contain both
226 + /// spaces and parentheses, since it is the basename of whatever was executed —
227 + /// which is attacker-chosen on a shared machine. Counting fields from the left
228 + /// therefore reads the wrong field for any process willing to be named
229 + /// `foo bar`, and this value exists precisely to make impersonation harder.
230 + fn start_time(stat: &str) -> Option<u64> {
231 + let after_comm = stat.rfind(')').map(|end| &stat[end + 1..])?;
232 + // Fields resume at 3 (state), so start-time (22) is the 20th value here.
233 + after_comm.split_whitespace().nth(19)?.parse().ok()
234 + }
235 +
236 + /// The locale polkit should render its message in.
237 + ///
238 + /// polkit takes the string and hands it to gettext. An empty one is legal and
239 + /// means the default, which is what the console wants when nothing is set:
240 + /// inventing `en_US.UTF-8` would be Alloy deciding the user's language.
241 + fn locale() -> String {
242 + std::env::var("LANG").unwrap_or_default()
243 + }
244 +
245 + fn helper_path() -> Option<PathBuf> {
246 + HELPERS
247 + .iter()
248 + .map(Path::new)
249 + .find(|path| path.exists())
250 + .map(Path::to_path_buf)
251 + }
252 +
253 + /// The object polkit calls into.
254 + struct Listener {
255 + helper: PathBuf,
256 + sender: SyncSender<Prompt>,
257 + }
258 +
259 + // Every argument below is owned and several are unread, both of which clippy
260 + // objects to and neither of which is this code's choice: the signature is
261 + // polkit's, the `#[interface]` macro deserializes into it, and an argument
262 + // dropped from the list is a method that no longer matches the interface it
263 + // claims to implement. Named in full and ignored where they are not wanted,
264 + // so the shape of what polkit sends stays legible here.
265 + #[allow(clippy::needless_pass_by_value)]
266 + #[interface(name = "org.freedesktop.PolicyKit1.AuthenticationAgent")]
267 + impl Listener {
268 + /// polkit is asking. This call blocks until the answer is known, which is
269 + /// the interface's contract rather than an oversight: returning is success
270 + /// and an error is failure, so there is nothing to return early with.
271 + ///
272 + /// `icon_name` and `details` are taken and dropped on the next line. The
273 + /// console has no icons, and the details map is a set of hints polkit's own
274 + /// GUI agents render beside the message; naming them in the signature
275 + /// rather than dropping them keeps the method matching the interface it
276 + /// claims to implement. Spelled without a leading underscore because the
277 + /// macro reads these names back out, which makes an underscore a lie about
278 + /// whether anything uses them.
279 + #[allow(clippy::too_many_arguments)]
280 + fn begin_authentication(
281 + &self,
282 + action_id: String,
283 + message: String,
284 + icon_name: String,
285 + details: HashMap<String, String>,
286 + cookie: String,
287 + identities: Vec<(String, HashMap<String, OwnedValue>)>,
288 + ) -> zbus::fdo::Result<()> {
289 + drop((icon_name, details));
290 +
291 + let user = choose_identity(&identities)
292 + .ok_or_else(|| zbus::fdo::Error::Failed("no identity this agent can ask".into()))?;
293 +
294 + converse(
295 + &self.helper,
296 + &user,
297 + &cookie,
298 + &action_id,
299 + &message,
300 + |prompt| {
301 + self.sender
302 + .send(prompt)
303 + .map_err(|_| anyhow!("the console stopped listening"))
304 + },
305 + )
306 + .map_err(|error| zbus::fdo::Error::Failed(error.to_string()))
307 + }
308 +
309 + /// polkit withdrew the question — the command it was for went away.
310 + ///
311 + /// Nothing to do. The helper is a child of the call that is still blocked
312 + /// in [`begin_authentication`](Listener::begin_authentication), and polkit
313 + /// closes that out itself; killing it from here would race the reply.
314 + #[allow(clippy::unused_self)]
315 + fn cancel_authentication(&self, cookie: String) -> zbus::fdo::Result<()> {
316 + drop(cookie);
317 + Ok(())
318 + }
319 + }
320 +
321 + /// Which of the identities polkit will accept this agent can actually ask.
322 + ///
323 + /// **This machine's own user first, and that ordering is the security-relevant
324 + /// part.** polkit sends every identity that would satisfy the action, which on
325 + /// a `wheel`-administered box is every administrator. Asking for the *first*
326 + /// one would mean a console at a laptop routinely prompting for root, teaching
327 + /// its owner to type the root password into a screen that could have been
328 + /// anything.
329 + ///
330 + /// Group identities are skipped. The helper takes a user name, so a group is
331 + /// not something this can ask for, and expanding one to its members would mean
332 + /// choosing an administrator on the user's behalf.
333 + fn choose_identity(identities: &[(String, HashMap<String, OwnedValue>)]) -> Option<String> {
334 + let uids: Vec<u32> = identities
335 + .iter()
336 + .filter(|(kind, _)| kind == "unix-user")
337 + .filter_map(|(_, details)| details.get("uid"))
338 + .filter_map(|uid| u32::try_from(uid).ok())
339 + .collect();
340 +
341 + let self_uid = self_uid();
342 + if let Some(uid) = self_uid.filter(|uid| uids.contains(uid)) {
343 + return username_of(uid);
344 + }
345 + uids.first().copied().and_then(username_of)
346 + }
347 +
348 + /// This process's real uid, from `/proc/self/status`.
349 + ///
350 + /// Read rather than asked of libc, which the console does not link. The `Uid:`
351 + /// line is four values — real, effective, saved, filesystem — and the first is
352 + /// the one that answers "who is sitting here".
353 + fn self_uid() -> Option<u32> {
354 + let status = std::fs::read_to_string("/proc/self/status").ok()?;
355 + status
356 + .lines()
357 + .find_map(|line| line.strip_prefix("Uid:"))?
358 + .split_whitespace()
359 + .next()?
360 + .parse()
361 + .ok()
362 + }
363 +
364 + /// Resolve a uid to a login name out of `/etc/passwd`.
365 + ///
366 + /// Parsed directly rather than through `getent`, for the same reason the uid is
367 + /// read from `/proc`: this is an authentication path, and a name that decides
368 + /// whose password is being asked for should not depend on a subprocess, a
369 + /// `$PATH`, or the console's command log — which would otherwise show a lookup
370 + /// the user never asked for, in the middle of a prompt.
371 + ///
372 + /// NSS is the cost, and it is a bounded one. A machine whose users live in LDAP
373 + /// or in systemd-homed has entries `/etc/passwd` does not carry, and this
374 + /// returns nothing for them rather than the wrong name. Alloy installs a local
375 + /// account (`install.rs`), so the case is a machine that has been joined to a
376 + /// directory since.
377 + fn username_of(uid: u32) -> Option<String> {
378 + let passwd = std::fs::read_to_string("/etc/passwd").ok()?;
379 + username_in(&passwd, uid)
380 + }
381 +
382 + /// The `/etc/passwd` lookup itself, split out so it can be tested without
383 + /// writing to the real one.
384 + fn username_in(passwd: &str, uid: u32) -> Option<String> {
385 + passwd.lines().find_map(|line| {
386 + let mut fields = line.split(':');
387 + let name = fields.next()?;
388 + let _password = fields.next()?;
389 + let found: u32 = fields.next()?.parse().ok()?;
390 + (found == uid).then(|| name.to_string())
391 + })
392 + }
393 +
394 + /// Run the helper's conversation to its end.
395 + ///
396 + /// `ask` is how a question reaches the screen; it returns once the prompt has
397 + /// been handed over, and the answer comes back through the prompt's own reply
398 + /// channel. Taking it as a closure is what keeps this function testable against
399 + /// a scripted helper with no D-Bus and no terminal anywhere near it.
400 + fn converse(
401 + helper: &Path,
402 + user: &str,
403 + cookie: &str,
404 + action_id: &str,
405 + message: &str,
406 + ask: impl Fn(Prompt) -> Result<()>,
407 + ) -> Result<()> {
408 + let mut child = Command::new(helper)
409 + .arg(user)
410 + .stdin(Stdio::piped())
411 + .stdout(Stdio::piped())
412 + .stderr(Stdio::null())
413 + .spawn()
414 + .with_context(|| format!("could not run {}", helper.display()))?;
415 +
416 + let mut stdin = child.stdin.take().context("the helper took no stdin")?;
417 + let stdout = child.stdout.take().context("the helper wrote no stdout")?;
418 + let mut lines = BufReader::new(stdout).lines();
419 +
420 + // The cookie first, on its own line. It is what polkit gave us and what the
421 + // helper hands back to prove this conversation is the one polkit asked for.
422 + writeln!(stdin, "{cookie}").context("the helper closed before the cookie")?;
423 +
424 + while let Some(line) = lines.next().transpose().context("the helper stopped")? {
425 + match Directive::parse(&line) {
426 + Some(Directive::Prompt { question, echo }) => {
427 + let (reply, answers) = sync_channel(0);
428 + ask(Prompt {
429 + action_id: action_id.to_string(),
430 + message: message.to_string(),
431 + question,
432 + echo,
433 + reply,
434 + })?;
435 +
436 + // A closed channel is the screen going away mid-prompt, which
437 + // is a dismissal rather than an answer.
438 + let answer = answers.recv().unwrap_or(None);
439 + let Some(answer) = answer else {
440 + finish(&mut child);
441 + bail!("dismissed");
442 + };
443 + writeln!(stdin, "{answer}").context("the helper closed mid-answer")?;
444 + }
445 + Some(Directive::Success) => {
446 + finish(&mut child);
447 + return Ok(());
448 + }
449 + Some(Directive::Failure) => {
450 + finish(&mut child);
451 + bail!("not authorized");
452 + }
453 + // PAM_ERROR_MSG and PAM_TEXT_INFO carry text for the user, and
454 + // anything unrecognized is a helper newer than this code. Neither
455 + // is a reason to abandon a conversation that is still going: the
456 + // helper says SUCCESS or FAILURE either way, and that is what this
457 + // waits for.
458 + None => {}
459 + }
460 + }
461 +
462 + finish(&mut child);
463 + bail!("the helper ended without saying whether it worked")
464 + }
465 +
466 + /// Close the helper out.
467 + ///
468 + /// Killing rather than waiting politely, because the paths that reach here have
469 + /// already decided the conversation is over and a helper mid-`pam_authenticate`
470 + /// can sit for as long as its PAM stack wants. The wait is what stops it
471 + /// becoming a zombie for the life of the console.
472 + fn finish(child: &mut Child) {
473 + let _ = child.kill();
474 + let _ = child.wait();
475 + }
476 +
477 + /// One line of the helper's protocol.
478 + #[derive(Debug, PartialEq, Eq)]
479 + enum Directive {
480 + Prompt { question: String, echo: bool },
481 + Success,
482 + Failure,
483 + }
484 +
485 + impl Directive {
486 + fn parse(line: &str) -> Option<Self> {
487 + // Trailing whitespace is significant in the other direction: the prompt
488 + // is usually `Password: ` and the trailing space is part of what a GUI
489 + // agent would draw. It is trimmed here because the console draws its
490 + // own label and the space would land in the middle of a rendered line.
491 + let (verb, rest) = line.split_once(' ').unwrap_or((line, ""));
492 + match verb {
493 + "PAM_PROMPT_ECHO_OFF" => Some(Directive::Prompt {
494 + question: rest.trim_end().to_string(),
495 + echo: false,
496 + }),
497 + "PAM_PROMPT_ECHO_ON" => Some(Directive::Prompt {
498 + question: rest.trim_end().to_string(),
499 + echo: true,
500 + }),
Lines truncated