├── .gitignore ├── .gitlab-ci.yml ├── .qubesbuilder ├── Makefile ├── Makefile.builder ├── README.md ├── archlinux └── PKGBUILD.in ├── debian ├── changelog ├── compat ├── control ├── copyright ├── qubes-gpg-split-tests.install ├── qubes-gpg-split.dirs ├── qubes-gpg-split.install ├── rules └── source │ ├── format │ └── options ├── doc ├── Makefile ├── qubes-gpg-client-wrapper.rst ├── qubes-gpg-client.rst └── qubes-gpg-import-key.rst ├── gpg-client-wrapper ├── gpg-import-key ├── qubes-gpg-split.tmpfiles ├── qubes-gpg.sh ├── qubes.Gpg.policy ├── qubes.Gpg.service ├── qubes.GpgImportKey.policy ├── qubes.GpgImportKey.service ├── rpm_spec ├── gpg-split-dom0.spec.in └── gpg-split.spec.in ├── src ├── .gitignore ├── Makefile ├── gpg-client.c ├── gpg-common.c ├── gpg-common.h ├── gpg-list-options.c ├── gpg-server.c ├── multiplex.c └── multiplex.h ├── tests ├── Makefile ├── setup.py ├── splitgpg │ ├── __init__.py │ └── tests.py ├── test_evolution.py ├── test_thunderbird.py └── whonix-clock-override.conf └── version /.gitignore: -------------------------------------------------------------------------------- 1 | rpm/ 2 | deb/ 3 | pkgs/ 4 | *.gz 5 | -------------------------------------------------------------------------------- /.gitlab-ci.yml: -------------------------------------------------------------------------------- 1 | include: 2 | - file: /r4.2/gitlab-base.yml 3 | project: QubesOS/qubes-continuous-integration 4 | - file: /r4.2/gitlab-host.yml 5 | project: QubesOS/qubes-continuous-integration 6 | - file: /r4.2/gitlab-vm.yml 7 | project: QubesOS/qubes-continuous-integration 8 | - file: /r4.3/gitlab-base.yml 9 | project: QubesOS/qubes-continuous-integration 10 | - file: /r4.3/gitlab-host.yml 11 | project: QubesOS/qubes-continuous-integration 12 | - file: /r4.3/gitlab-vm.yml 13 | project: QubesOS/qubes-continuous-integration 14 | -------------------------------------------------------------------------------- /.qubesbuilder: -------------------------------------------------------------------------------- 1 | host: 2 | rpm: 3 | build: 4 | - rpm_spec/gpg-split-dom0.spec 5 | vm: 6 | rpm: 7 | build: 8 | - rpm_spec/gpg-split.spec 9 | deb: 10 | build: 11 | - debian 12 | archlinux: 13 | build: 14 | - archlinux 15 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # The Qubes OS Project, http://www.qubes-os.org 3 | # 4 | # Copyright (C) 2011 Marek Marczykowski 5 | # 6 | # This program is free software; you can redistribute it and/or 7 | # modify it under the terms of the GNU General Public License 8 | # as published by the Free Software Foundation; either version 2 9 | # of the License, or (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program; if not, write to the Free Software 18 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 19 | # 20 | # 21 | 22 | LIBDIR ?= /usr/lib 23 | 24 | build: 25 | $(MAKE) -C src 26 | $(MAKE) -C doc manpages 27 | 28 | install-vm-common: 29 | install -d $(DESTDIR)$(LIBDIR)/qubes-gpg-split 30 | install -t $(DESTDIR)$(LIBDIR)/qubes-gpg-split src/gpg-server 31 | install -D src/gpg-client $(DESTDIR)/usr/bin/qubes-gpg-client 32 | install -D gpg-client-wrapper $(DESTDIR)/usr/bin/qubes-gpg-client-wrapper 33 | install -D gpg-import-key $(DESTDIR)/usr/bin/qubes-gpg-import-key 34 | install -D qubes.Gpg.service $(DESTDIR)/etc/qubes-rpc/qubes.Gpg 35 | install -D qubes.GpgImportKey.service $(DESTDIR)/etc/qubes-rpc/qubes.GpgImportKey 36 | install -m 0644 -D qubes-gpg.sh $(DESTDIR)/etc/profile.d/qubes-gpg.sh 37 | install -D qubes-gpg-split.tmpfiles $(DESTDIR)/usr/lib/tmpfiles.d/qubes-gpg-split.conf 38 | make -C tests install-vm 39 | make -C doc install 40 | 41 | install-vm-deb: install-vm-common 42 | make -C tests install-vm-deb 43 | 44 | install-vm-fedora: install-vm-common 45 | install-vm: install-vm-common 46 | 47 | clean: 48 | $(MAKE) -C src clean 49 | $(MAKE) -C doc clean 50 | rm -rf debian/changelog.* 51 | rm -rf pkgs 52 | -------------------------------------------------------------------------------- /Makefile.builder: -------------------------------------------------------------------------------- 1 | ifeq ($(PACKAGE_SET),dom0) 2 | RPM_SPEC_FILES := rpm_spec/gpg-split-dom0.spec 3 | else ifeq ($(PACKAGE_SET),vm) 4 | ifneq ($(filter $(DISTRIBUTION), debian qubuntu),) 5 | DEBIAN_BUILD_DIRS := debian 6 | endif 7 | RPM_SPEC_FILES := rpm_spec/gpg-split.spec 8 | ARCH_BUILD_DIRS := archlinux 9 | 10 | # Support for new packaging 11 | ifneq ($(filter $(DISTRIBUTION), archlinux),) 12 | VERSION := $(file <$(ORIG_SRC)/$(DIST_SRC)/version) 13 | GIT_TARBALL_NAME ?= qubes-gpg-split-$(VERSION)-1.tar.gz 14 | SOURCE_COPY_IN := source-archlinux-copy-in 15 | 16 | source-archlinux-copy-in: PKGBUILD = $(CHROOT_DIR)/$(DIST_SRC)/$(ARCH_BUILD_DIRS)/PKGBUILD 17 | source-archlinux-copy-in: 18 | cp $(PKGBUILD).in $(CHROOT_DIR)/$(DIST_SRC)/PKGBUILD 19 | sed -i "s/@VERSION@/$(VERSION)/g" $(CHROOT_DIR)/$(DIST_SRC)/PKGBUILD 20 | sed -i "s/@REL@/1/g" $(CHROOT_DIR)/$(DIST_SRC)/PKGBUILD 21 | endif 22 | 23 | endif 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Qubes Split GPG 2 | =============== 3 | Split GPG implements a concept similar to having a smart card with your private 4 | GPG keys, except that the role of the "smart card" plays another Qubes AppVM. 5 | This way one, not-so-trusted domain, e.g. the one where Thunderbird is running, 6 | can delegate all crypto operations, such as encryption/decryption and signing to 7 | another, more trusted, network-isolated, domain. This way the compromise of your 8 | domain where Thunderbird or another client app is running – arguably a 9 | not-so-unthinkable scenario – does not allow the attacker to automatically also 10 | steal all your keys. (We should make a rather obvious comment here that the 11 | so-often-used passphrases on private keys are pretty meaningless because the 12 | attacker can easily set up a simple backdoor which would wait until the user 13 | enters the passphrase and steal the key then.) 14 | 15 | More in-depth usage information can be found 16 | [here](https://www.qubes-os.org/doc/split-gpg/). 17 | -------------------------------------------------------------------------------- /archlinux/PKGBUILD.in: -------------------------------------------------------------------------------- 1 | # Maintainer: Frédéric Pierret (fepitre) 2 | 3 | pkgname=qubes-gpg-split 4 | pkgver=@VERSION@ 5 | pkgrel=@REL@ 6 | pkgdesc="The Qubes service for secure gpg separation" 7 | arch=("x86_64") 8 | url="http://qubes-os.org/" 9 | license=('GPL') 10 | depends=('gnupg' 'zenity') 11 | makedepends=(pkg-config make gcc pandoc) 12 | _pkgnvr="${pkgname}-${pkgver}-${pkgrel}" 13 | source=("${_pkgnvr}.tar.gz") 14 | sha256sums=(SKIP) 15 | 16 | build() { 17 | cd "${_pkgnvr}" 18 | make 19 | } 20 | 21 | package() { 22 | cd "${_pkgnvr}" 23 | 24 | # shellcheck disable=SC2154 25 | make install-vm \ 26 | DESTDIR="$pkgdir" \ 27 | LIBDIR=/usr/lib \ 28 | USRLIBDIR=/usr/lib \ 29 | SYSLIBDIR=/usr/lib 30 | } 31 | -------------------------------------------------------------------------------- /debian/changelog: -------------------------------------------------------------------------------- 1 | qubes-gpg-split (2.0.77-1) unstable; urgency=medium 2 | 3 | * tests: adjust error handling 4 | * tests: adjust disabling donation prompt, yet again 5 | 6 | -- Marek Marczykowski-Górecki Mon, 07 Apr 2025 16:21:50 +0200 7 | 8 | qubes-gpg-split (2.0.76-1) unstable; urgency=medium 9 | 10 | * tests: update for Thunderbird version/config in Whonix 17 11 | 12 | -- Marek Marczykowski-Górecki Fri, 14 Mar 2025 22:21:59 +0100 13 | 14 | qubes-gpg-split (2.0.75-1) unstable; urgency=medium 15 | 16 | * tests: adjust for Thunderbird 128 17 | * ci: drop R4.1, add R4.3 18 | * tests: adjust for 'push button' -> 'button' role change 19 | 20 | -- Marek Marczykowski-Górecki Thu, 03 Oct 2024 04:41:23 +0200 21 | 22 | qubes-gpg-split (2.0.74-1) unstable; urgency=medium 23 | 24 | * tests: assertEquals -> assertEqual 25 | 26 | -- Marek Marczykowski-Górecki Sat, 24 Aug 2024 02:41:41 +0200 27 | 28 | qubes-gpg-split (2.0.73-1) unstable; urgency=medium 29 | 30 | * rpm: mark dom0 package as noarch 31 | 32 | -- Marek Marczykowski-Górecki Mon, 12 Aug 2024 17:39:54 +0200 33 | 34 | qubes-gpg-split (2.0.72-1) unstable; urgency=medium 35 | 36 | * Fix build error on redefined _FORTIFY_SOURCE 37 | * tests: handle both Save and Save All dialogs 38 | 39 | -- Marek Marczykowski-Górecki Thu, 18 Jul 2024 04:26:30 +0200 40 | 41 | qubes-gpg-split (2.0.71-1) unstable; urgency=medium 42 | 43 | * rpm: adjust BR for directory ownership check in openSUSE 44 | * rpm: do not package directory in /var/run 45 | * Use /usr/lib/tmpfiles.d instead of /etc/tmpfiles.d 46 | * Do not install man pages and profile.d files as executable 47 | * Make /run/qubes-gpg-split only group writable 48 | * rpm: fix license tag 49 | * tests: switch from smtpd to aiosmtpd 50 | 51 | -- Marek Marczykowski-Górecki Sat, 27 Apr 2024 03:35:56 +0200 52 | 53 | qubes-gpg-split (2.0.70-1) unstable; urgency=medium 54 | 55 | * tests: try harder to avoid donation prompt during tests 56 | * tests: update for Thunderbird 115 57 | 58 | -- Marek Marczykowski-Górecki Thu, 26 Oct 2023 04:45:43 +0200 59 | 60 | qubes-gpg-split (2.0.69-1) unstable; urgency=medium 61 | 62 | * tests: use distribution's dogtail package 63 | 64 | -- Marek Marczykowski-Górecki Tue, 08 Aug 2023 12:49:49 +0200 65 | 66 | qubes-gpg-split (2.0.68-1) unstable; urgency=medium 67 | 68 | [ Demi Marie Obenour ] 69 | * Do not allow + to separate subpacket numbers 70 | 71 | [ Marek Marczykowski-Górecki ] 72 | * tests: fix clicking top buttons in evolution 73 | * Ignore --auto-key-locate local,wkd 74 | 75 | -- Marek Marczykowski-Górecki Fri, 30 Jun 2023 15:57:28 +0200 76 | 77 | qubes-gpg-split (2.0.67-1) unstable; urgency=medium 78 | 79 | [ Frédéric Pierret (fepitre) ] 80 | * Rework Archlinux packaging 81 | * Bare support for new packaging with PKGBUILD.in 82 | 83 | -- Marek Marczykowski-Górecki Wed, 26 Apr 2023 05:25:15 +0200 84 | 85 | qubes-gpg-split (2.0.66-1) unstable; urgency=medium 86 | 87 | * Don't install policy on R4.2 88 | 89 | -- Marek Marczykowski-Górecki Fri, 03 Feb 2023 19:18:45 +0100 90 | 91 | qubes-gpg-split (2.0.65-1) unstable; urgency=medium 92 | 93 | [ Demi Marie Obenour ] 94 | * Use ppoll() instead of pselect() 95 | * Clean up spec file cruft 96 | 97 | [ Marek Marczykowski-Górecki ] 98 | * tests: disable end-of-year message, and similar popups 99 | 100 | [ Frédéric Pierret (fepitre) ] 101 | * spec: add BR python3-setuptools 102 | 103 | -- Marek Marczykowski-Górecki Thu, 19 Jan 2023 12:23:34 +0100 104 | 105 | qubes-gpg-split (2.0.64-1) unstable; urgency=medium 106 | 107 | * tests: Fix retry_if_failed decorator 108 | * tests: update for Thunderbird 102 109 | 110 | -- Marek Marczykowski-Górecki Tue, 15 Nov 2022 04:26:36 +0100 111 | 112 | qubes-gpg-split (2.0.63-1) unstable; urgency=medium 113 | 114 | [ Demi Marie Obenour ] 115 | * Disallow --command-fd 116 | * Force batch mode 117 | * Prevent GnuPG from using a closed file descriptor 118 | * Force --exit-on-status-write-error 119 | 120 | -- Marek Marczykowski-Górecki Sat, 06 Aug 2022 17:54:31 +0200 121 | 122 | qubes-gpg-split (2.0.62-1) unstable; urgency=medium 123 | 124 | [ Demi Marie Obenour ] 125 | * Emulate --quiet in terms of -q 126 | 127 | -- Marek Marczykowski-Górecki Fri, 29 Jul 2022 17:38:52 +0200 128 | 129 | qubes-gpg-split (2.0.61-1) unstable; urgency=medium 130 | 131 | [ Demi Marie Obenour ] 132 | * Do not ignore -q and --quiet 133 | * Properly NULL-terminate argument list 134 | * Fix misleading comments 135 | * Avoid UBSAN splat on max-length input 136 | * Ensure that argc is never 0 137 | * Fix cast to a potentially-misaligned pointer 138 | * Do not send argv[0] to server 139 | * Avoid dropping a trailing empty string 140 | * Allow --export-ownertrust 141 | * Allow exporting public keyring backups 142 | * Allow --show-session-key 143 | * gpg-client: check for too many file names 144 | * Avoid hang due to premature file descriptor close 145 | * Have bash choose an unused file descriptor 146 | * Use common code for file descriptor lists 147 | * Refuse to use the same FD for both reading and writing 148 | * Make is_client a const global variable 149 | * Mark received file descriptors CLOEXEC 150 | * Check that standard streams are open 151 | * Allow "qubes-gpg-client --verify - a.sig" 152 | 153 | -- Marek Marczykowski-Górecki Wed, 27 Jul 2022 04:09:20 +0200 154 | 155 | qubes-gpg-split (2.0.60-1) unstable; urgency=medium 156 | 157 | [ Demi Marie Obenour ] 158 | * Allow --list-options show-sig-subpackets 159 | 160 | -- Marek Marczykowski-Górecki Mon, 30 May 2022 03:01:37 +0200 161 | 162 | qubes-gpg-split (2.0.59-1) unstable; urgency=medium 163 | 164 | [ Frédéric Pierret (fepitre) ] 165 | * Drop Travis CI 166 | 167 | [ Demi Marie Obenour ] 168 | * Allow show-photos as a list or verify option 169 | 170 | [ Marek Marczykowski-Górecki ] 171 | * tests: update Evolution test for newer settings dialog layout 172 | * tests: close Evolution settings via xdotool 173 | * tests: adjust message view in Evolution 174 | * tests: really just sign in test_010_send_receive_signed_only 175 | 176 | [ Frédéric Pierret (fepitre) ] 177 | * Add Qubes Builder v2 integration 178 | * .qubesbuilder: replace 'spec' by 'build' 179 | 180 | -- Marek Marczykowski-Górecki Mon, 09 May 2022 17:15:16 +0200 181 | 182 | qubes-gpg-split (2.0.58-1) unstable; urgency=medium 183 | 184 | [ Marek Marczykowski-Górecki ] 185 | * test: avoid false negative from sending status dialog 186 | 187 | [ Demi Marie Obenour ] 188 | * Reject options ignored by the wrapper script 189 | * Drop --pgp2 190 | * Fix list and verify option processing 191 | 192 | -- Marek Marczykowski-Górecki Sat, 02 Apr 2022 02:51:22 +0200 193 | 194 | qubes-gpg-split (2.0.57-1) unstable; urgency=medium 195 | 196 | [ Demi Marie Obenour ] 197 | * Harden move_fds against bad file descriptor values 198 | * Avoid out of bounds access to closed_fds 199 | * Properly validate file descriptor arguments 200 | * Open signature file with O_CLOEXEC and O_NOCTTY 201 | * Fix assertion for new file descriptor limit 202 | * Avoid closing a needed file descriptor 203 | * Use pipe2(O_CLOEXEC) instead of closing fds 204 | * Use _Bool for a type that must always be 0 or 1 205 | * Use _exit(), not exit(), in the child after fork() 206 | * Set empty handler for SIGPIPE 207 | * process_out(): treat an empty FD_SET properly 208 | * process_io(): block SIGCHLD 209 | * Fix remaining hangs 210 | * Allow --attribute-fd 211 | 212 | [ Patrick Schleizer ] 213 | * add alias `--sign-with` to `-u` 214 | * Update gpg-client-wrapper 215 | 216 | -- Marek Marczykowski-Górecki Mon, 28 Feb 2022 21:53:38 +0100 217 | 218 | qubes-gpg-split (2.0.56-1) unstable; urgency=medium 219 | 220 | [ deeplow ] 221 | * tests: increase search attempts for thunderbird start 222 | * tests: earlier detection of failed message sending 223 | * print failed to send error 224 | * merge timeout approaches 225 | * undo removal of resetting dogtail search count 226 | * tests: reduce whonix boot clock rand during testing 227 | 228 | [ Demi Marie Obenour ] 229 | * Translate ‘--detach-sig’ to ‘--detach-sign’ 230 | * Allow '--clear-sign' as well as '--clearsign' 231 | * Fix the Arch build 232 | * Forcibly disable dirmngr 233 | * Ignore several options used by kmail 234 | * Allow --with-secret 235 | * More robust file descriptor handling 236 | * Ditch pipecat and fix hangs 237 | * Ignore various options used by Mailpile 238 | * Allow `--utf8-strings` 239 | * Reject non-UTF-8 compatible display charsets 240 | * Recognize -o in addition to --output 241 | * Use set_output for -o as well as --output 242 | * `--output` is more than just stdout redirection 243 | * Prevent GPG from launching a photo viewer 244 | * Sanitize arguments to --list-options 245 | 246 | -- Marek Marczykowski-Górecki Mon, 14 Feb 2022 21:08:45 +0100 247 | 248 | qubes-gpg-split (2.0.55-1) unstable; urgency=medium 249 | 250 | [ deeplow ] 251 | * tests: tb add PGP key to acct via user.js 252 | * tests: remove deprecated enigmail HACK & tb restart 253 | 254 | [ Demi Marie Obenour ] 255 | * Fix quoting bug 256 | * Trivial client cleanup 257 | * Add trailing newline in error message 258 | * Remove ‘--rfc1991’ 259 | * Fix some bugs in the wrapper script 260 | * Remove options already ignored in wrapper script 261 | * Do not allow remote qube to control argv[0] of gpg 262 | * Better error message if GPG tries to read a password 263 | * Fix short option handling in wrapper script 264 | * Reject abbreviated long options 265 | * Server: reduce getopt_long(3)’s attack surface 266 | * Never permute arguments and options 267 | * Require arguments to be printable UTF-8 268 | * Fix some bugs in the wrapper script 269 | * Convert --detach into --detach-sign 270 | * Use bash's [[ consistently 271 | * Consolidate redundant case branches 272 | * Avoid mishandling empty strings 273 | 274 | [ Marek Marczykowski-Górecki ] 275 | * Do not use fancy unicode quotes 276 | 277 | -- Marek Marczykowski-Górecki Sun, 12 Dec 2021 19:45:54 +0100 278 | 279 | qubes-gpg-split (2.0.54-1) unstable; urgency=medium 280 | 281 | [ Alyssa Ross ] 282 | * Fix off-by-one in fd path replacement 283 | * Make fd path replacement length check clearer 284 | 285 | [ Demi Marie Obenour ] 286 | * Tighten up parsing of file descriptors 287 | 288 | [ deeplow ] 289 | * test: add IMAP server as tb 91+ removed movemail 290 | * tests: thunderbird use user.js for default profile 291 | * tests: remove autoconf and local account setup code 292 | * tests: autotype IMAP password 293 | * tests: fix bug where arguments were missing a space 294 | * tests: more fixes for tb 91+ 295 | * tests: remove deprecated tb OpenPGP message code 296 | * tests: tb fix OpenPGP opening in tb91+ 297 | * tests: make thunderbird user.js dynamically generated 298 | * tests: check mail manually instead 299 | * tests: improve resilience of OpenPGP button click 300 | * tests: support windows with role 'frame' or 'dialog' 301 | * tests: retry "enter imap password" on fail 302 | 303 | -- Marek Marczykowski-Górecki Tue, 09 Nov 2021 05:08:38 +0100 304 | 305 | qubes-gpg-split (2.0.53-1) unstable; urgency=medium 306 | 307 | * Correctly mark --s2k-* options as requiring an argument 308 | * Properly handle gpg's command options 309 | * Fix --with-colons declaration 310 | * Remove duplicated --fixed-list-mode 311 | * Add --default-recipient option to the parser 312 | * Fix forbidden option reporting 313 | * Add --display option to the parser 314 | * tests: include stderr in the failure message 315 | * tests: option arguments parsing 316 | 317 | -- Marek Marczykowski-Górecki Thu, 09 Sep 2021 22:27:04 +0200 318 | 319 | qubes-gpg-split (2.0.52-1) unstable; urgency=medium 320 | 321 | [ deeplow ] 322 | * tests: remove deprecated enigmail and tb<78 code 323 | * tests: remove creation of .gnupg (enigmail req.) 324 | * tests: manage thunderbird through dedicated class 325 | * add retry_if_failed wrapper to setup function 326 | * retry_if_failed add_local_account (now idempotent) 327 | * use functools wraps in decorator 328 | 329 | [ Marek Marczykowski-Górecki ] 330 | * tests: avoid redirect to dogtail repo 331 | 332 | -- Marek Marczykowski-Górecki Thu, 02 Sep 2021 03:47:58 +0200 333 | 334 | qubes-gpg-split (2.0.51-1) unstable; urgency=medium 335 | 336 | [ Frédéric Pierret (fepitre) ] 337 | * spec: add BR make 338 | 339 | [ deeplow ] 340 | * tests: master key sign-only + subkey 341 | * tests: generate w/ subkeys in remaining tests 342 | * test: fix missed clicks on "open file" dialogue 343 | 344 | -- Marek Marczykowski-Górecki Fri, 06 Aug 2021 03:20:31 +0200 345 | 346 | qubes-gpg-split (2.0.50-1) unstable; urgency=medium 347 | 348 | [ Frédéric Pierret (fepitre) ] 349 | * Add .gitlab-ci.yml 350 | * Improve reproducibility 351 | * Allow to override defined CFLAGS 352 | 353 | [ Marek Marczykowski-Górecki ] 354 | * tests: adjust key import dialog in debian-10 355 | 356 | -- Marek Marczykowski-Górecki Tue, 08 Dec 2020 18:31:56 +0100 357 | 358 | qubes-gpg-split (2.0.49-1) unstable; urgency=medium 359 | 360 | * Adjust tests for Debian 10 361 | * rpm: skip python3-dogtail on CentOS 362 | * tests: try to get the main TB window, not any splash screen 363 | 364 | -- Marek Marczykowski-Górecki Fri, 13 Nov 2020 03:12:57 +0100 365 | 366 | qubes-gpg-split (2.0.48-1) unstable; urgency=medium 367 | 368 | [ Frédéric Pierret (fepitre) ] 369 | * Update makefile 370 | * Update travis 371 | * spec: replace hardcoded python3 372 | 373 | [ Ludovic Bellier ] 374 | * Fix tmpfiles.d using a directory link 375 | 376 | [ Frédéric Pierret (fepitre) ] 377 | * test_thunderbird: make PEP8 happier 378 | * test_thunderbird: handle thunderbird-78+ 379 | * tests: enhance behavior and increase action and default delays 380 | * Fix tests for Thunderbird 68 381 | * tests: do show_menu_bar after skip_autoconf 382 | 383 | -- Marek Marczykowski-Górecki Sat, 10 Oct 2020 05:16:13 +0200 384 | 385 | qubes-gpg-split (2.0.47-1) unstable; urgency=medium 386 | 387 | * Ignore tty/display related options 388 | 389 | -- Marek Marczykowski-Górecki Tue, 16 Jun 2020 14:19:07 +0200 390 | 391 | qubes-gpg-split (2.0.46-1) unstable; urgency=medium 392 | 393 | * Add --unwrap to allowed options 394 | 395 | -- Marek Marczykowski-Górecki Fri, 12 Jun 2020 04:18:48 +0200 396 | 397 | qubes-gpg-split (2.0.45-1) unstable; urgency=medium 398 | 399 | [ Marta Marczykowska-Górecka ] 400 | * Made split GPG permission question box nicer 401 | 402 | [ Marek Marczykowski-Górecki ] 403 | * rpm: do not drop executable bit from qubes.GpgImportKey service 404 | * Make qubes.GpgImportKey service a proper script 405 | 406 | -- Marek Marczykowski-Górecki Tue, 25 Feb 2020 19:55:51 +0100 407 | 408 | qubes-gpg-split (2.0.44-1) unstable; urgency=medium 409 | 410 | [ Johanna Abrahamsson ] 411 | * Accept --personal-{cipher,...}-preferences with option-argument 412 | 413 | [ Abel Luck ] 414 | * Whitelist opts to get mozilla/sops compatibility 415 | 416 | -- Marek Marczykowski-Górecki Tue, 28 Jan 2020 03:51:00 +0100 417 | 418 | qubes-gpg-split (2.0.43-1) unstable; urgency=medium 419 | 420 | * Don't include python2 tests on new dom0 (based on >f28) 421 | * travis: switch to fc31 dom0 422 | * Fix qrexec policy permission 423 | 424 | -- Marek Marczykowski-Górecki Mon, 06 Jan 2020 02:59:41 +0100 425 | 426 | qubes-gpg-split (2.0.42-1) unstable; urgency=medium 427 | 428 | * tests: improve handling compose window in TB 68 429 | 430 | -- Marek Marczykowski-Górecki Sat, 07 Dec 2019 05:14:48 +0100 431 | 432 | qubes-gpg-split (2.0.41-1) unstable; urgency=medium 433 | 434 | [ Johanna Abrahamsson ] 435 | * add ignore for --disable-dirmngr option 436 | 437 | [ Frédéric Pierret (fepitre) ] 438 | * travis: switch to bionic 439 | 440 | -- Marek Marczykowski-Górecki Mon, 28 Oct 2019 04:24:17 +0100 441 | 442 | qubes-gpg-split (2.0.40-1) unstable; urgency=medium 443 | 444 | [ w1k1n9cc ] 445 | * Git is great but not in that case ;-) 446 | * wrong identation 447 | 448 | [ Marek Marczykowski-Górecki ] 449 | * tests: give more time for the actual test 450 | 451 | -- Marek Marczykowski-Górecki Sat, 05 Oct 2019 21:52:10 +0200 452 | 453 | qubes-gpg-split (2.0.39-1) unstable; urgency=medium 454 | 455 | [ LawAbidingCactus ] 456 | * remove duplicated option in manpage 457 | 458 | [ Marek Marczykowski-Górecki ] 459 | * tests: adjust for Thunderbird 68 460 | * travis: switch to xenial, update distributions, drop R3.2 461 | * tests: accept "Qubes Attachments" addon 462 | 463 | -- Marek Marczykowski-Górecki Mon, 30 Sep 2019 00:08:00 +0200 464 | 465 | qubes-gpg-split (2.0.38-1) unstable; urgency=medium 466 | 467 | [ redshiftzero ] 468 | * GpgImportKey: pass --no-tty through 469 | 470 | -- Marek Marczykowski-Górecki Thu, 16 May 2019 19:09:03 +0200 471 | 472 | qubes-gpg-split (2.0.37-1) wheezy; urgency=medium 473 | 474 | * Do not block actual gpg operation on notification 475 | 476 | -- Marek Marczykowski-Górecki Sat, 11 May 2019 18:56:10 +0200 477 | 478 | qubes-gpg-split (2.0.36-1) unstable; urgency=medium 479 | 480 | [ Alex Jordan ] 481 | * Whitelist --export-ssh-key option 482 | 483 | -- Marek Marczykowski-Górecki Thu, 21 Mar 2019 03:59:44 +0100 484 | 485 | qubes-gpg-split (2.0.35-1) unstable; urgency=medium 486 | 487 | * tests: improve error reporting 488 | * tests: update for Thunderbird 60 489 | * tests: increase timeout for Thunderbird start 490 | * tests: adjust for Whonix, increase timeouts 491 | * tests: adjust for TB version in Whonix 14 492 | * debian: drop autotools in debian/rules 493 | * tests: force C.UTF-8 locale 494 | * tests: force C.UTF-8 locale during thunderbird setup too 495 | * rpm: fix Source0 tag 496 | * rpm: specify python binaries to build with 497 | * rpm: fix python macros one more time 498 | * travis: update to R4.1 499 | 500 | -- Marek Marczykowski-Górecki Sun, 10 Mar 2019 01:48:25 +0100 501 | 502 | qubes-gpg-split (2.0.34-1) unstable; urgency=medium 503 | 504 | * debian: don't create orig.tar.gz manually 505 | * tests: add xdotool dependency 506 | * tests: improve workaround for Whonix's time randomization 507 | * tests: improve timeout handling in thunderbird tests 508 | * Ignore --photo-viewer 509 | * tests: Evolution integration 510 | * tests: Evolution on Debian 511 | * debian: install test_evolution.py 512 | * tests/thunderbird: give a file choosing dialog a little time 513 | * Move confirmation prompt to qubes.Gpg shell script 514 | * Wait for GUI session before asking the user for confirmation 515 | 516 | -- Marek Marczykowski-Górecki Thu, 06 Dec 2018 14:39:23 +0100 517 | 518 | qubes-gpg-split (2.0.33-1) unstable; urgency=medium 519 | 520 | * tests: handle desynced clock in Whonix also in basic tests 521 | * tests: avoid non-ASCII characters in test results 522 | * rpm: add BR: gcc 523 | * tests: fix race condition on .gnupg creation in key import tests 524 | 525 | -- Marek Marczykowski-Górecki Tue, 09 Oct 2018 23:57:45 +0200 526 | 527 | qubes-gpg-split (2.0.32-1) unstable; urgency=medium 528 | 529 | * tests: fix race condition on .gnupg creation 530 | * tests: convert to gpg2 531 | * tests/thunderbid: improve handling file selection dialog 532 | * tests/thunderbird: improve error reporting 533 | * tests/thunderbird: disable pEp before setting anything else 534 | * tests/thunderbird: make Enigmail settings work with TB 60 535 | * tests/thunderbird: handle addons manager in TB 60+ 536 | * tests/thunderbird: autoconfiguration prompt in TB 60 537 | * tests/thunderbird: tweaks for Thunderbird 60 538 | * tests/thunderbird: disable html message composing 539 | * tests/thunderbird: use Node.child instead of GenericPredicate 540 | * tests/thunderbird: switch to python3 541 | * travis: update config 542 | 543 | -- Marek Marczykowski-Górecki Sun, 16 Sep 2018 04:42:33 +0200 544 | 545 | qubes-gpg-split (2.0.31-1) unstable; urgency=medium 546 | 547 | * Allow --logger-fd option 548 | * Emulate --log-file with --logger-fd 549 | 550 | -- Marek Marczykowski-Górecki Tue, 19 Jun 2018 00:46:47 +0200 551 | 552 | qubes-gpg-split (2.0.30-1) unstable; urgency=medium 553 | 554 | [ Marek Marczykowski-Górecki ] 555 | * Add --sender and --set-filename option to the whitelist 556 | * tests: update for Enigmail 2.0 557 | 558 | [ Frédéric Pierret ] 559 | * Create .spec.in and Source0 560 | * src: add debug '-g' 561 | * spec.in: add changelog placeholder 562 | * Fix GCC8 warnings 563 | 564 | [ Marek Marczykowski-Górecki ] 565 | * travis: add R4.0, remove R3.1 566 | 567 | -- Marek Marczykowski-Górecki Sun, 15 Apr 2018 04:41:31 +0200 568 | 569 | qubes-gpg-split (2.0.29-1) unstable; urgency=medium 570 | 571 | [ hark ] 572 | * Use local gpg when access to keyring is not needed. 573 | * use array 574 | * remove passprase-fd 575 | 576 | [ Marek Marczykowski-Górecki ] 577 | * Add --no-auto-check-trustdb option to the whitelist 578 | 579 | -- Marek Marczykowski-Górecki Wed, 28 Mar 2018 03:54:29 +0200 580 | 581 | qubes-gpg-split (2.0.28-1) unstable; urgency=medium 582 | 583 | * tests: install also for python3 - for Qubes 4.0 584 | * tests: avoid interactive password prompt on gpg 2.1 585 | * rpm: fix build dependencies for python3 586 | * tests: some more places for gpg 2.1 password prompt, improve 587 | reporting, 588 | * Add support for --enable-special-filenames 589 | * Add more options to the whitelist 590 | * Fix handling -q option 591 | * Whitelist --{cert,sig,set}-notation options 592 | * Add --verify-options to the whitelist 593 | 594 | -- Marek Marczykowski-Górecki Tue, 27 Feb 2018 15:24:03 +0100 595 | 596 | qubes-gpg-split (2.0.27-1) unstable; urgency=medium 597 | 598 | [ Nedyalko Andreev ] 599 | * Fix archlinux package - remove /var/run 600 | * Fix minor indentation and shellcheck issues 601 | 602 | [ Olivier MEDOC ] 603 | * archlinux: rename package to follow other distributions naming 604 | 605 | [ Marek Marczykowski-Górecki ] 606 | * Add list-options to the whitelist 607 | 608 | -- Marek Marczykowski-Górecki Tue, 21 Nov 2017 04:44:17 +0100 609 | 610 | qubes-gpg-split (2.0.26-1) unstable; urgency=medium 611 | 612 | [ Marek Marczykowski-Górecki ] 613 | * debian: fix Depends: 614 | 615 | [ anoadragon453 ] 616 | * Add expire time to GPG access notifications 617 | 618 | [ Marek Marczykowski-Górecki ] 619 | * Convert tabs to spaces 620 | * Add hidden recipients related options 621 | * Whitelist --keyid-format 622 | * Add --throw-keyids/--no-throw-keyids options 623 | 624 | -- Marek Marczykowski-Górecki Wed, 26 Jul 2017 13:23:53 +0200 625 | 626 | qubes-gpg-split (2.0.25-1) unstable; urgency=medium 627 | 628 | [ Nicklaus McClendon ] 629 | * Removed .travis.yml debootstrap fix 630 | * Added basic README.md 631 | * Added manpages 632 | * Added pandoc to Arch package dependencies 633 | * Print to standard out if output is '-' 634 | 635 | [ Marek Marczykowski-Górecki ] 636 | * typo fix 637 | 638 | -- Marek Marczykowski-Górecki Sat, 13 May 2017 15:02:19 +0200 639 | 640 | qubes-gpg-split (2.0.24-1) wheezy; urgency=medium 641 | 642 | [ Marek Marczykowski-Górecki ] 643 | * tests: exclude Whonix Gateway 644 | * tests: disable logging to file to avoid utf-8 handling problems 645 | * tests: give TB some time to handle message 646 | * tests: workaround time desynchronization issues on Whonix 647 | 648 | [ Jacob Jenner Rasmussen ] 649 | * archlinux support 650 | 651 | [ Marek Marczykowski-Górecki ] 652 | * Don't trash stderr with zenity messages 653 | 654 | -- Marek Marczykowski-Górecki Fri, 18 Nov 2016 02:00:39 +0100 655 | 656 | qubes-gpg-split (2.0.23-1) wheezy; urgency=medium 657 | 658 | * Don't mix stdout and --output content 659 | * Implement the --status-fd workflow for other data-outputing options 660 | * tests: add test for --status-fd 1 and --output conflict 661 | 662 | -- Marek Marczykowski-Górecki Sun, 17 Jul 2016 05:22:53 +0200 663 | 664 | qubes-gpg-split (2.0.22-1) wheezy; urgency=medium 665 | 666 | * Redirect qubes-gpg-client-wrapper --import into qubes-gpg-import-key 667 | * Fix handling --verify option validation 668 | * Allow --export option 669 | * Allow --enable-progress-filter option 670 | * Allow --hidden-recipient option 671 | * tests: inline signature and mails with attachments 672 | 673 | -- Marek Marczykowski-Górecki Tue, 12 Jul 2016 00:42:10 +0200 674 | 675 | qubes-gpg-split (2.0.21-1) wheezy; urgency=medium 676 | 677 | * travis: initial version 678 | * rpm: add missing python-setuptools BR 679 | 680 | -- Marek Marczykowski-Górecki Tue, 21 Jun 2016 04:29:37 +0200 681 | 682 | qubes-gpg-split (2.0.20-1) wheezy; urgency=medium 683 | 684 | [ Marek Marczykowski-Górecki ] 685 | * Remove duplicated entries 686 | 687 | [ viq ] 688 | * Attempt at making GPG password managers to work 689 | 690 | [ Marek Marczykowski-Górecki ] 691 | * Allow --no-encrypt-to and --compress-algo 692 | * Minor fix for handling --output 693 | * tests: test for --output option 694 | * tests: typo 695 | * tests: adjust for newer Thunderbird/Enigmail 696 | 697 | -- Marek Marczykowski-Górecki Mon, 02 May 2016 03:53:14 +0200 698 | 699 | qubes-gpg-split (2.0.19-1) wheezy; urgency=medium 700 | 701 | [ Boris Prüßmann ] 702 | * Use gpg2 in GpgImportKey 703 | 704 | [ Marek Marczykowski-Górecki ] 705 | * Fix gpg version in package dependencies 706 | * Add tests for direct usage and in Thunderbird 707 | * tests: enable network access for test VMs 708 | * Use /usr/bin/gpg2 instead of /bin/gpg2 709 | * tests: fix qrexec policy setting 710 | * tests: add qubes-gpg-import-key test 711 | 712 | -- Marek Marczykowski-Górecki Thu, 24 Mar 2016 20:47:02 +0100 713 | 714 | qubes-gpg-split (2.0.18-1) wheezy; urgency=medium 715 | 716 | * Ignore --keyserver-options 717 | 718 | -- Marek Marczykowski-Górecki Tue, 01 Mar 2016 00:09:38 +0100 719 | 720 | qubes-gpg-split (2.0.17-1) wheezy; urgency=medium 721 | 722 | [ Noah Vesely ] 723 | * Split GPG depends on zenity for user prompts 724 | 725 | -- Marek Marczykowski-Górecki Sat, 07 Nov 2015 04:36:01 +0100 726 | 727 | qubes-gpg-split (2.0.16-1) wheezy; urgency=medium 728 | 729 | [ Axon ] 730 | * Use gpg2 by default 731 | 732 | -- Marek Marczykowski-Górecki Sat, 07 Nov 2015 03:43:37 +0100 733 | 734 | qubes-gpg-split (2.0.15-1) wheezy; urgency=medium 735 | 736 | * Fix handling stderr write errors 737 | 738 | -- Marek Marczykowski-Górecki Fri, 30 Oct 2015 15:20:05 +0100 739 | 740 | qubes-gpg-split (2.0.14-1) wheezy; urgency=medium 741 | 742 | * Add a couple of options useful in batch mode to allowed list 743 | 744 | -- Marek Marczykowski-Górecki Tue, 20 Oct 2015 13:51:11 +0200 745 | 746 | qubes-gpg-split (2.0.13-1) wheezy; urgency=medium 747 | 748 | * Add missing dependency on zenity 749 | 750 | -- Marek Marczykowski-Górecki Sat, 05 Sep 2015 01:28:44 +0200 751 | 752 | qubes-gpg-split (2.0.12-1) wheezy; urgency=medium 753 | 754 | * gitignore pkgs 755 | 756 | -- Marek Marczykowski-Górecki Thu, 03 Sep 2015 02:46:47 +0200 757 | 758 | qubes-gpg-split (2.0.11-1) wheezy; urgency=medium 759 | 760 | [ Jason Mehring ] 761 | * Added Debian packaging 762 | * debian: Change path of gpg to /usr/bin/gpg from /bin/gpg 763 | * debian: Added deb/ to .gitignore 764 | * Use DEBIAN_PARSER variable provided by builder 765 | 766 | [ Marek Marczykowski-Górecki ] 767 | * Fix compile flags 768 | 769 | -- Marek Marczykowski-Górecki Fri, 27 Mar 2015 00:33:35 +0100 770 | 771 | qubes-gpg-split (2.0.10-1) unstable; urgency=low 772 | 773 | * Initial Release. 774 | 775 | -- Jason Mehring Tue, 25 Feb 2015 00:00:00 +0000 776 | -------------------------------------------------------------------------------- /debian/compat: -------------------------------------------------------------------------------- 1 | 9 2 | -------------------------------------------------------------------------------- /debian/control: -------------------------------------------------------------------------------- 1 | Source: qubes-gpg-split 2 | Section: admin 3 | Priority: extra 4 | Maintainer: Jason Mehring 5 | Build-Depends: debhelper (>= 9~), pandoc, quilt 6 | Standards-Version: 3.9.5 7 | Homepage: http://www.qubes-os.org 8 | 9 | Package: qubes-gpg-split 10 | Section: admin 11 | Architecture: amd64 12 | Depends: 13 | gnupg2, 14 | zenity, 15 | ${shlibs:Depends}, 16 | ${misc:Depends} 17 | Description: The Qubes service for secure gpg separation 18 | 19 | Package: qubes-gpg-split-tests 20 | Section: admin 21 | Architecture: amd64 22 | Depends: 23 | qubes-gpg-split, 24 | python3-pyatspi, 25 | python3-dogtail, 26 | python3-aiosmtpd, 27 | dovecot-imapd, 28 | xdotool, 29 | ${misc:Depends} 30 | Description: Helper files for Split GPG tests 31 | -------------------------------------------------------------------------------- /debian/copyright: -------------------------------------------------------------------------------- 1 | Format: http://www.debian.org/doc/packaging-manuals/copyright-format/1.0/ 2 | Upstream-Name: qubes-gpg-split 3 | Source: 4 | 5 | Files: * 6 | Copyright: 2014-2015 Qubes Developers 7 | License: GPL-2+ 8 | This package is free software; you can redistribute it and/or modify 9 | it under the terms of the GNU General Public License as published by 10 | the Free Software Foundation; either version 2 of the License, or 11 | (at your option) any later version. 12 | . 13 | This package is distributed in the hope that it will be useful, 14 | but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | GNU General Public License for more details. 17 | . 18 | You should have received a copy of the GNU General Public License 19 | along with this program. If not, see 20 | . 21 | On Debian systems, the complete text of the GNU General 22 | Public License version 2 can be found in "/usr/share/common-licenses/GPL-2". 23 | 24 | Files: debian/* 25 | Copyright: 2015 Jason Mehring License: GPL-2+ 26 | This package is free software; you can redistribute it and/or modify 27 | it under the terms of the GNU General Public License as published by 28 | the Free Software Foundation; either version 2 of the License, or 29 | (at your option) any later version. 30 | . 31 | This package is distributed in the hope that it will be useful, 32 | but WITHOUT ANY WARRANTY; without even the implied warranty of 33 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 34 | GNU General Public License for more details. 35 | . 36 | You should have received a copy of the GNU General Public License 37 | along with this program. If not, see 38 | . 39 | On Debian systems, the complete text of the GNU General 40 | Public License version 2 can be found in "/usr/share/common-licenses/GPL-2". 41 | 42 | -------------------------------------------------------------------------------- /debian/qubes-gpg-split-tests.install: -------------------------------------------------------------------------------- 1 | usr/lib/qubes-gpg-split/test_thunderbird.py 2 | usr/lib/qubes-gpg-split/test_evolution.py 3 | lib/systemd/system/bootclockrandomization.service.d/override.conf 4 | -------------------------------------------------------------------------------- /debian/qubes-gpg-split.dirs: -------------------------------------------------------------------------------- 1 | usr/lib/qubes-gpg-split 2 | var/run/qubes-gpg-split 3 | -------------------------------------------------------------------------------- /debian/qubes-gpg-split.install: -------------------------------------------------------------------------------- 1 | usr/lib/qubes-gpg-split/gpg-server 2 | usr/bin/qubes-gpg-client 3 | usr/bin/qubes-gpg-client-wrapper 4 | usr/bin/qubes-gpg-import-key 5 | etc/qubes-rpc/qubes.Gpg 6 | etc/qubes-rpc/qubes.GpgImportKey 7 | etc/profile.d/qubes-gpg.sh 8 | usr/lib/tmpfiles.d/qubes-gpg-split.conf 9 | usr/share/man/man1/qubes-gpg-client.1.gz 10 | usr/share/man/man1/qubes-gpg-client-wrapper.1.gz 11 | usr/share/man/man1/qubes-gpg-import-key.1.gz 12 | -------------------------------------------------------------------------------- /debian/rules: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make -f 2 | # -*- makefile -*- 3 | 4 | # Uncomment this to turn on verbose mode. 5 | #export DH_VERBOSE=1 6 | 7 | export DEB_BUILD_MAINT_OPTIONS = hardening=+all reproducible=+fixfilepath 8 | DPKG_EXPORT_BUILDFLAGS = 1 9 | include /usr/share/dpkg/default.mk 10 | 11 | export DESTDIR=$(shell readlink -m .)/debian/tmp 12 | 13 | %: 14 | dh $@ 15 | 16 | override_dh_auto_build: 17 | dh_clean --keep 18 | make build 19 | 20 | override_dh_auto_install: 21 | make install-vm-deb 22 | 23 | override_dh_install: 24 | dh_install --fail-missing 25 | -------------------------------------------------------------------------------- /debian/source/format: -------------------------------------------------------------------------------- 1 | 3.0 (quilt) 2 | -------------------------------------------------------------------------------- /debian/source/options: -------------------------------------------------------------------------------- 1 | extend-diff-ignore = "(^|/)(.git/.*)$" 2 | extend-diff-ignore = "(^|/)(deb/.*)$" 3 | extend-diff-ignore = "(^|/)(pkgs/.*)$" 4 | extend-diff-ignore = "(^|/)(rpm/.*)$" 5 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | PANDOC=pandoc -s -f rst -t man 2 | 3 | DOCS=$(patsubst %.rst,%.1.gz,$(wildcard *.rst)) 4 | 5 | help: 6 | @echo "make manpages -- generate manpages" 7 | @echo "make install -- generate manpages and copy them to /usr/share/man" 8 | 9 | manpages: $(DOCS) 10 | 11 | install: manpages 12 | install -d $(DESTDIR)/usr/share/man/man1 13 | install -m 0644 -D $(DOCS) $(DESTDIR)/usr/share/man/man1/ 14 | 15 | %.1: %.rst 16 | $(PANDOC) $< > $@ 17 | 18 | %.1.gz: %.1 19 | gzip -f $< 20 | 21 | clean: 22 | rm -f $(DOCS) 23 | -------------------------------------------------------------------------------- /doc/qubes-gpg-client-wrapper.rst: -------------------------------------------------------------------------------- 1 | =========================== 2 | qubes-gpg-client-wrapper(1) 3 | =========================== 4 | 5 | NAME 6 | ==== 7 | qubes-gpg-client-wrapper - a wrapper around qubes-gpg-client and qubes-gpg-import-key 8 | 9 | SYNOPSIS 10 | ======== 11 | | qubes-gpg-client-wrapper 12 | 13 | DESCRIPTION 14 | =========== 15 | qubes-gpg-client-wrapper wraps around qubes-gpg-client and qubes-gpg-import-key 16 | to better mimic the functionality provided by native gpg2. This overall allows 17 | qubes-gpg-client-wrapper to be used as a drop in replacement for programs like 18 | Enigmail and git. 19 | 20 | AUTHORS 21 | ======= 22 | | Marek Marczykowski 23 | 24 | -------------------------------------------------------------------------------- /doc/qubes-gpg-client.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | QUBES-GPG-CLIENT(1) 3 | =================== 4 | 5 | NAME 6 | ==== 7 | qubes-gpg-client - communicates with a seperate GPG Qube to enable Qubes Split GPG 8 | 9 | SYNOPSIS 10 | ======== 11 | | qubes-gpg-client 12 | 13 | DESCRIPTION 14 | =========== 15 | qubes-gpg-client functions similarly to gpg2, but performs all sensitive tasks 16 | in a trusted Qube, defined by $QUBES_GPG_DOMAIN. Options involving network 17 | connectivity (key servers, etc) are not supported, as the trusted GPG Qube is 18 | intended to not have network connectivity. 19 | 20 | OPTIONS 21 | ======= 22 | Listed are the options that can be passed to the GPG Qube. More information can be 23 | found about their functionality in the gpg2 manpage. 24 | 25 | -b, --detach-sign 26 | 27 | -a, --armor 28 | 29 | -c, --symmetric 30 | 31 | -d, --decrypt 32 | 33 | -e, --encrypt 34 | 35 | -k, --list-keys 36 | 37 | -K, --list-secret-keys 38 | 39 | -n, --dry-run 40 | 41 | -o, --output 42 | 43 | -q, --quiet 44 | 45 | -r, --recipient 46 | 47 | -R, --hidden-recipient 48 | 49 | -s, --sign 50 | 51 | -t, --textmode 52 | 53 | -u, --local-user 54 | 55 | -v, --verbose 56 | 57 | --always-trust 58 | 59 | --batch 60 | 61 | --cert-digest-algo 62 | 63 | --charset 64 | 65 | --cipher-algo 66 | 67 | --clearsign 68 | 69 | --clear-sign 70 | 71 | --command-fd 72 | 73 | --comment 74 | 75 | --compress-algo 76 | 77 | --digest-algo 78 | 79 | --disable-cipher-algo 80 | 81 | --disable-mdc 82 | 83 | --disable-pubkey-algo 84 | 85 | --display-charset 86 | 87 | --emit-version 88 | 89 | --enable-progress-filter 90 | 91 | --encrypt-to 92 | 93 | --export 94 | 95 | --fingerprint 96 | 97 | --fixed-list-mode 98 | 99 | --force-mdc 100 | 101 | --force-v3-sigs 102 | 103 | --force-v4-certs 104 | 105 | --gnupg 106 | 107 | --list-config 108 | 109 | --list-only 110 | 111 | --list-public-keys 112 | 113 | --list-sigs 114 | 115 | --max-output 116 | 117 | --no-comments 118 | 119 | --no-encrypt-to 120 | 121 | --no-emit-version 122 | 123 | --no-force-v3-sigs 124 | 125 | --no-force-v4-certs 126 | 127 | --no-greeting 128 | 129 | --no-secmem-warning 130 | 131 | --no-tty 132 | 133 | --no-verbose 134 | 135 | --openpgp 136 | 137 | --personal-cipher-preferences 138 | 139 | --personal-compress-preferences 140 | 141 | --personal-digest-preferences 142 | 143 | --pgp2 144 | 145 | --pgp6 146 | 147 | --pgp7 148 | 149 | --pgp8 150 | 151 | --rfc1991 152 | 153 | --rfc2440 154 | 155 | --rfc4880 156 | 157 | --s2k-cipher-algo 158 | 159 | --s2k-count 160 | 161 | --s2k-digest-algo 162 | 163 | --s2k-mode 164 | 165 | --status-fd 166 | 167 | --store 168 | 169 | --trust-model 170 | 171 | --use-agent 172 | 173 | --verify 174 | 175 | --version 176 | 177 | --with-colons 178 | 179 | --with-fingerprint 180 | 181 | --with-keygrip 182 | 183 | AUTHORS 184 | ======= 185 | | Marek Marczykowski 186 | -------------------------------------------------------------------------------- /doc/qubes-gpg-import-key.rst: -------------------------------------------------------------------------------- 1 | ======================= 2 | qubes-gpg-import-key(1) 3 | ======================= 4 | 5 | NAME 6 | ==== 7 | qubes-gpg-import-key - imports a GPG key to the GPG Qube's keyring 8 | 9 | SYNOPSIS 10 | ======== 11 | | qubes-gpg-import-key 12 | 13 | DESCRIPTION 14 | =========== 15 | qubes-gpg-import-key imports the passed key to the keyring of the GPG Qube, using 16 | Qubes RPC. This will cause a prompt in dom0 to confirm the import before 17 | proceeding. 18 | 19 | AUTHORS 20 | ======= 21 | | Marek Marczykowski 22 | -------------------------------------------------------------------------------- /gpg-client-wrapper: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | options=() # the buffer array for the parameters 4 | eoo=0 # end of options reached 5 | output=0 # do we try to write to file 6 | target='' # where do we try to write to 7 | special_filenames=0 # --enable-special-filenames was given 8 | localgpg=0 #use local gpg (for ex --gen-rand etc.) 9 | origargs=( "$@" ) 10 | 11 | set_output () { 12 | if (( output )); then 13 | echo 'Output file already set'>&2 14 | exit 1 15 | fi 16 | output=1 target=$1 17 | } 18 | 19 | check_charset () { 20 | if ! [[ "$1" =~ ^[uU][tT][fF]-?8$ ]]; then 21 | printf 'Unsupported character set %q\n' "$1" 22 | exit 1 23 | fi 24 | } 25 | 26 | while (( $# )); do 27 | if ! ((eoo)); then 28 | case "$1" in 29 | #when those arguments are present will not use the keyring, and so they can be executed with local gpg 30 | # can be used in combination with sign 31 | #-c) 32 | # localgpg=1 33 | # break 34 | #;; 35 | --gen-rand|--gen-prime|--enarmor|--dearmor|--print-md|--help|-h) 36 | localgpg=1 37 | break 38 | ;; 39 | --no-default-keyring) 40 | # this is not possible with split gpg right? 41 | localgpg=1 42 | break 43 | ;; 44 | --import) 45 | # ignore all the options and only collect file name(s) - if any 46 | shift 47 | exec qubes-gpg-import-key "$@" 48 | ;; 49 | # Keyserver options makes no sense for offline GPG VM, so it is 50 | # rejected by qubes-gpg-client and qubes-gpg-server. But since 51 | # it is forced by Torbirdy extension, simply ignore the option. 52 | --keyserver-options=*) 53 | shift 54 | ;; 55 | # --quiet is safe and is accepted by the current server, but 56 | # old versions rejected it. Use -q instead, which is 57 | # accepted in all versions. 58 | --quiet) 59 | options+=(-q) 60 | shift 61 | ;; 62 | --keyserver-options) 63 | if [[ "$#" -lt 2 ]]; then 64 | printf 'Missing argument to %s\n' "$1" >&2 65 | exit 1 66 | fi 67 | shift 2 68 | ;; 69 | # Using dirmngr in an offline GPG VM makes no sense, however 70 | # qubes-gpg-client does not recognize the command line option 71 | # --disable-dirmngr so to avoid an error message we ignore 72 | # this option. 73 | --disable-dirmngr) 74 | shift 75 | ;; 76 | # --photo-viewer shouldn't be passed to the backend as it allow 77 | # arbitrary command execution 78 | --photo-viewer=*) 79 | shift 80 | ;; 81 | --photo-viewer) 82 | if [[ "$#" -lt 2 ]]; then 83 | printf 'Missing argument to %s\n' "$1" >&2 84 | exit 1 85 | fi 86 | shift 2 87 | ;; 88 | # --command-fd is used by reprepro, and looks safe. 89 | # However, it turns out that GnuPG trusts the data it 90 | # receives on this FD, so the backend has to reject it. 91 | --command-fd=*) 92 | shift 93 | ;; 94 | --command-fd) 95 | if [[ "$#" -lt 2 ]]; then 96 | printf 'Missing argument to %s\n' "$1" >&2 97 | exit 1 98 | fi 99 | shift 2 100 | ;; 101 | # Used by Mailpile, see https://github.com/QubesOS/qubes-issues/issues/3485 102 | --expert|--pinentry-mode=*|--passphrase-fd=*|--no-use-agent) 103 | shift 104 | ;; 105 | --pinentry-mode|--passphrase-fd) 106 | if [[ "$#" -lt 2 ]]; then 107 | printf 'Missing argument to %s\n' "$1" >&2 108 | exit 1 109 | fi 110 | shift 2 111 | ;; 112 | # ignore tty/display related options - those are meaningless in another VM 113 | --ttyname=*|--display=*|--ttytype=*|--lc-messages=*|--lc-ctype=*|--no-tty|--xauthority=*) 114 | shift 115 | ;; 116 | --ttyname|--ttytype|--display|--lc-messages|--lc-ctype|--xauthority) 117 | if [[ "$#" -lt 2 ]]; then 118 | printf 'Missing argument to %s\n' "$1" >&2 119 | exit 1 120 | fi 121 | shift 2 122 | ;; 123 | --enable-special-filenames) 124 | special_filenames=1 125 | shift 126 | ;; 127 | # Deprecated legacy options 128 | --disable-mdc|\ 129 | --force-mdc|\ 130 | --force-v3-sigs|\ 131 | --force-v4-certs|\ 132 | --no-sk-comments|\ 133 | --use-agent) 134 | shift 135 | ;; 136 | # Ignored options 137 | --yes) 138 | shift 139 | ;; 140 | --detach|--detach-sig) 141 | options+=( --detach-sign ) 142 | shift 143 | ;; 144 | --output) 145 | if [[ "$#" -lt 2 ]]; then 146 | printf 'Missing argument to %s\n' "$1" >&2 147 | exit 1 148 | fi 149 | set_output "$2" 150 | shift 2 151 | ;; 152 | --output=*) 153 | set_output "${1:9}" 154 | shift 155 | ;; 156 | --status-fd|\ 157 | --logger-fd|\ 158 | --attribute-fd) 159 | if [[ "$#" -lt 2 ]]; then 160 | printf 'Missing argument to %s\n' "$1" >&2 161 | exit 1 162 | elif [[ "$2" =~ ^(0x)?0*1$ ]]; then 163 | # don't use stdout for status fd, since it might be later 164 | # redirected to a file with --output 165 | exec {fd_for_stdout}>&1 166 | options+=( "$1" "$fd_for_stdout" ) 167 | else 168 | options+=( "$1" "$2" ) 169 | fi 170 | shift 2 171 | ;; 172 | --status-fd=*|\ 173 | --logger-fd=*|\ 174 | --attribute-fd=*) 175 | if [[ $1 =~ ^([^=]*=)(0x)?0*1$ ]]; then 176 | # don't use stdout for status fd, since it might be later 177 | # redirected to a file with --output 178 | exec {fd_for_stdout}>&1 179 | options+=( "${BASH_REMATCH[1]}$fd_for_stdout" ) 180 | else 181 | options+=( "$1" ) 182 | fi 183 | shift 184 | ;; 185 | --log-file=*) 186 | # rejected by split-gpg to not allow a write to arbitrary file 187 | # on the backend side; emulate using --logger-fd 188 | exec {fd_for_logfile}>"${1:11}" 189 | options+=( "--logger-fd" "$fd_for_logfile" ) 190 | shift 191 | ;; 192 | --log-file) 193 | if [[ "$#" -lt 2 ]]; then 194 | printf 'Missing argument to %s\n' "$1" >&2 195 | exit 1 196 | fi 197 | # rejected by split-gpg to not allow a write to arbitrary file 198 | # on the backend side; emulate using --logger-fd 199 | exec {fd_for_logfile}>"$2" 200 | options+=( "--logger-fd" "$fd_for_logfile" ) 201 | shift 2 202 | ;; 203 | --display-charset=*) 204 | check_charset "${1:18}" 205 | shift 206 | ;; 207 | --charset=*) 208 | check_charset "${1:10}" 209 | shift 210 | ;; 211 | --charset|--display-charset) 212 | if [[ "$#" -lt 2 ]]; then 213 | printf 'Missing argument to %s\n' "$1" >&2 214 | exit 1 215 | fi 216 | check_charset "$2" 217 | shift 2 218 | ;; 219 | # alias '--sign-with' to '-u' (short for '--local-user') 220 | # https://github.com/QubesOS/qubes-issues/issues/3325#issuecomment-1039877769 221 | --sign-with=*) 222 | options+=("-u" "${1:12}") 223 | shift 224 | ;; 225 | --sign-with) 226 | if (( $# >= 2 )); then 227 | options+=("-u" "$2") 228 | shift 2 229 | else # let qubes-gpg-client error out 230 | options+=("$1") 231 | shift 232 | fi 233 | ;; 234 | --auto-key-locate) 235 | # Generally not safe to use, but allow using with the default 236 | # value (and ignore the option in such case as it's no-op) 237 | # See https://github.com/QubesOS/qubes-issues/issues/8287 238 | if [ "$2" = "local,wkd" ]; then 239 | shift 2 240 | else 241 | printf 'Unsupported --auto-key-locate %s (only local,wkd value is allowed)\n' "$2" >&2 242 | exit 1 243 | fi 244 | ;; 245 | --cert-digest-algo|\ 246 | --cert-notation|\ 247 | --cipher-algo|\ 248 | --command-fd|\ 249 | --comment|\ 250 | --compress-algo|\ 251 | --default-recipient|\ 252 | --digest-algo|\ 253 | --disable-cipher-algo|\ 254 | --disable-pubkey-algo|\ 255 | --encrypt-to|\ 256 | --hidden-encrypt-to|\ 257 | --hidden-recipient|\ 258 | --list-options|\ 259 | --local-user|\ 260 | --keyid-format|\ 261 | --max-output|\ 262 | --personal-cipher-preferences|\ 263 | --personal-compress-preferences|\ 264 | --personal-digest-preferences|\ 265 | --recipient|\ 266 | --s2k-cipher-algo|\ 267 | --s2k-count|\ 268 | --s2k-digest-algo|\ 269 | --s2k-mode|\ 270 | --sender|\ 271 | --sig-notation|\ 272 | --set-filename|\ 273 | --set-notation|\ 274 | --trust-model|\ 275 | --trusted-key|\ 276 | --try-secret-key|\ 277 | --verify-options) 278 | if (( $# >= 2 )); then 279 | options+=("$1" "$2") 280 | shift 2 281 | else # let qubes-gpg-client error out 282 | options+=("$1") 283 | shift 284 | fi 285 | ;; 286 | -[!-]*) 287 | if [[ "$1" =~ ^-[bacdekKnqst]*[NrRu]$ ]] && (( $# >= 2 )); then 288 | options+=("$1" "$2") 289 | shift 2 290 | elif [[ "$1" =~ ^(-[bacdekKnqst]*)o(.*)$ ]]; then 291 | if (( ${#BASH_REMATCH[1]} > 1 )); then 292 | options+=("${BASH_REMATCH[1]}") 293 | fi 294 | if (( ${#BASH_REMATCH[2]} > 0 )); then 295 | set_output "${BASH_REMATCH[2]}" 296 | shift 297 | elif (( $# >= 2 )); then 298 | set_output "$2" 299 | shift 2 300 | else 301 | printf 'Missing argument to -o\n' >&2 302 | exit 1 303 | fi 304 | else # if $# is too small, let qubes-gpg-client error out 305 | options+=("$1") 306 | shift 307 | fi 308 | ;; 309 | --) 310 | eoo=1 311 | options+=("$1") 312 | shift 313 | ;; 314 | *) 315 | options+=("$1") 316 | shift 317 | ;; 318 | esac 319 | else 320 | if ((special_filenames)) && [[ "$1" = "-&"* ]]; then 321 | options+=("/proc/self/fd/${1#-&}") 322 | else 323 | options+=("$1") 324 | fi 325 | shift 326 | fi 327 | done 328 | 329 | if [[ "$localgpg" -eq 1 ]] 330 | then 331 | exec /usr/bin/gpg "${origargs[@]}" 332 | exit $? 333 | fi 334 | 335 | . /etc/profile.d/qubes-gpg.sh 336 | 337 | if ((output)); then 338 | if [[ "$target" != '-' ]]; then exec > "$target"; fi 339 | exec qubes-gpg-client -o- "${options[@]}" 340 | else 341 | exec qubes-gpg-client "${options[@]}" 342 | fi 343 | -------------------------------------------------------------------------------- /gpg-import-key: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # The Qubes OS Project, http://www.qubes-os.org 4 | # 5 | # Copyright (C) 2014 Marek Marczykowski-Górecki 6 | # 7 | # This program is free software; you can redistribute it and/or 8 | # modify it under the terms of the GNU General Public License 9 | # as published by the Free Software Foundation; either version 2 10 | # of the License, or (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program; if not, write to the Free Software 19 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 20 | # 21 | # 22 | 23 | if [ -z "$QUBES_GPG_DOMAIN" ]; then 24 | echo "ERROR: Destination domain not defined! Set it with QUBES_GPG_DOMAIN env variable." >&2 25 | exit 1 26 | fi 27 | 28 | if [ $# -gt 0 ]; then 29 | exec /usr/lib/qubes/qrexec-client-vm $QUBES_GPG_DOMAIN qubes.GpgImportKey /bin/cat "$@" 30 | else 31 | exec /usr/lib/qubes/qrexec-client-vm $QUBES_GPG_DOMAIN qubes.GpgImportKey /bin/sh -c 'cat /proc/self/fd/$SAVED_FD_0' 32 | fi 33 | -------------------------------------------------------------------------------- /qubes-gpg-split.tmpfiles: -------------------------------------------------------------------------------- 1 | d /run/qubes-gpg-split 775 root qubes 2 | -------------------------------------------------------------------------------- /qubes-gpg.sh: -------------------------------------------------------------------------------- 1 | #### Setting for client vm #### 2 | # VM with GPG server (default) 3 | #export QUBES_GPG_DOMAIN="gpgvm" 4 | 5 | # Per-VM override 6 | if [ -s /rw/config/gpg-split-domain ]; then 7 | export QUBES_GPG_DOMAIN=`cat /rw/config/gpg-split-domain` 8 | fi 9 | 10 | #### Settings for GPG VM #### 11 | # Remember user choice for this many seconds - default 5min (300s) 12 | #export QUBES_GPG_AUTOACCEPT=300 13 | -------------------------------------------------------------------------------- /qubes.Gpg.policy: -------------------------------------------------------------------------------- 1 | $anyvm $anyvm ask 2 | -------------------------------------------------------------------------------- /qubes.Gpg.service: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | unit() { 4 | case "$1" in 5 | 0s);; 6 | 1s) echo " 1 second";; 7 | *s) echo " ${1%s} seconds";; 8 | 0m);; 9 | 1m) echo " 1 minute";; 10 | *m) echo " ${1%m} minutes";; 11 | 0h);; 12 | 1h) echo " 1 hour";; 13 | *h) echo " ${1%h} hours";; 14 | 0d);; 15 | 1d) echo " 1 day";; 16 | *d) echo " ${1%d} days";; 17 | esac 18 | } 19 | 20 | if [ -z "$QUBES_GPG_AUTOACCEPT" ]; then 21 | QUBES_GPG_AUTOACCEPT=300 22 | fi 23 | 24 | days="$(( $QUBES_GPG_AUTOACCEPT / (3600*24) ))d"; 25 | hours="$(( ( $QUBES_GPG_AUTOACCEPT % (3600*24) ) / 3600 ))h"; 26 | minutes="$(( ( $QUBES_GPG_AUTOACCEPT % 3600 ) / 60 ))m"; 27 | seconds="$(( $QUBES_GPG_AUTOACCEPT % 60 ))s"; 28 | 29 | stat_file="/var/run/qubes-gpg-split/stat.$QREXEC_REMOTE_DOMAIN" 30 | stat_time=$(stat -c %Y "$stat_file" 2>/dev/null || echo 0) 31 | now=$(date +%s) 32 | if [ $(($stat_time + $QUBES_GPG_AUTOACCEPT)) -lt "$now" ]; then 33 | echo "$USER" | /etc/qubes-rpc/qubes.WaitForSession >/dev/null 2>/dev/null 34 | msg_text="Do you allow VM '$QREXEC_REMOTE_DOMAIN' to access your GPG keys" 35 | msg_text="$msg_text\n(now and for the following $(unit $days)$(unit $hours)$(unit $minutes)$(unit $seconds))?" 36 | zenity --question --no-wrap --text "$msg_text" 2>/dev/null /dev/null || exit 1 37 | touch "$stat_file" 38 | fi 39 | notify-send "Keyring access from domain: $QREXEC_REMOTE_DOMAIN" --expire-time=1000 /dev/null 2>/dev/null & 40 | /usr/lib/qubes-gpg-split/gpg-server /usr/bin/gpg2 "$QREXEC_REMOTE_DOMAIN" 41 | -------------------------------------------------------------------------------- /qubes.GpgImportKey.policy: -------------------------------------------------------------------------------- 1 | $anyvm $anyvm ask 2 | -------------------------------------------------------------------------------- /qubes.GpgImportKey.service: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | /usr/bin/gpg2 --no-tty --import 3 | -------------------------------------------------------------------------------- /rpm_spec/gpg-split-dom0.spec.in: -------------------------------------------------------------------------------- 1 | # 2 | # This is the SPEC file for creating binary and source RPMs for the VMs. 3 | # 4 | # 5 | # The Qubes OS Project, http://www.qubes-os.org 6 | # 7 | # Copyright (C) 2011 Marek Marczykowski 8 | # 9 | # This program is free software; you can redistribute it and/or 10 | # modify it under the terms of the GNU General Public License 11 | # as published by the Free Software Foundation; either version 2 12 | # of the License, or (at your option) any later version. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program; if not, write to the Free Software 21 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 22 | 23 | Name: qubes-gpg-split-dom0 24 | Version: @VERSION@ 25 | Release: 1%{dist} 26 | Summary: Qubes dom0 package for gpg split 27 | 28 | Group: Qubes 29 | Vendor: Invisible Things Lab 30 | License: GPL 31 | URL: https://www.qubes-os.org 32 | 33 | BuildArch: noarch 34 | BuildRequires: make 35 | BuildRequires: python3-rpm-macros 36 | BuildRequires: python3-setuptools 37 | BuildRequires: python3-devel 38 | Requires: gpg 39 | 40 | Source0: qubes-gpg-split-%{version}.tar.gz 41 | 42 | %description 43 | This package include integration tests. It used to include default policy, but 44 | it was removed in Qubes OS 4.2, due to new graphical tool handling that now. 45 | 46 | %prep 47 | %setup -q -n qubes-gpg-split-%{version} 48 | 49 | %install 50 | rm -rf $RPM_BUILD_ROOT 51 | %if 0%{?fedora} <= 32 52 | install -m 0664 -D qubes.Gpg.policy $RPM_BUILD_ROOT/etc/qubes-rpc/policy/qubes.Gpg 53 | install -m 0664 -D qubes.GpgImportKey.policy $RPM_BUILD_ROOT/etc/qubes-rpc/policy/qubes.GpgImportKey 54 | %endif 55 | make -C tests install-dom0-py3 DESTDIR=$RPM_BUILD_ROOT PYTHON2=%{__python3} 56 | 57 | %clean 58 | rm -rf $RPM_BUILD_ROOT 59 | 60 | %files 61 | %if 0%{?fedora} <= 32 62 | %config(noreplace) %attr(0664,root,qubes) /etc/qubes-rpc/policy/qubes.Gpg 63 | %config(noreplace) %attr(0664,root,qubes) /etc/qubes-rpc/policy/qubes.GpgImportKey 64 | %endif 65 | %dir %{python3_sitelib}/splitgpg-*.egg-info 66 | %{python3_sitelib}/splitgpg-*.egg-info/* 67 | %{python3_sitelib}/splitgpg 68 | 69 | %changelog 70 | @CHANGELOG@ 71 | -------------------------------------------------------------------------------- /rpm_spec/gpg-split.spec.in: -------------------------------------------------------------------------------- 1 | # 2 | # This is the SPEC file for creating binary and source RPMs for the VMs. 3 | # 4 | # 5 | # The Qubes OS Project, http://www.qubes-os.org 6 | # 7 | # Copyright (C) 2011 Marek Marczykowski 8 | # 9 | # This program is free software; you can redistribute it and/or 10 | # modify it under the terms of the GNU General Public License 11 | # as published by the Free Software Foundation; either version 2 12 | # of the License, or (at your option) any later version. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program; if not, write to the Free Software 21 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 22 | # 23 | # 24 | 25 | 26 | Name: qubes-gpg-split 27 | Version: @VERSION@ 28 | Release: 1%{dist} 29 | Summary: The Qubes service for secure gpg separation 30 | 31 | Group: Qubes 32 | Vendor: Invisible Things Lab 33 | License: GPL-2.0-or-later 34 | URL: http://www.qubes-os.org 35 | 36 | BuildRequires: make 37 | BuildRequires: gcc 38 | BuildRequires: pandoc 39 | BuildRequires: python3-setuptools 40 | %if 0%{?is_opensuse} 41 | # for directory ownership 42 | BuildRequires: qubes-core-qrexec 43 | %endif 44 | 45 | Requires: gnupg2 46 | Requires: zenity 47 | 48 | 49 | Source0: %{name}-%{version}.tar.gz 50 | 51 | %description 52 | The Qubes service for delegating gpg actions to other VM. You can keep keys in 53 | secure (even network isolated) VM and only pass data to it for 54 | signing/decryption. 55 | 56 | %package tests 57 | Summary: Tests for Split GPG 58 | Requires: %{name} 59 | %if ! 0%{?rhel} 60 | Requires: python%{python3_pkgversion}-dogtail 61 | %endif 62 | Requires: xdotool 63 | Requires: dovecot 64 | Requires: python%{python3_pkgversion}-aiosmtpd 65 | 66 | %description tests 67 | Helper scripts for Split GPG tests. 68 | 69 | %prep 70 | %setup -q 71 | 72 | %build 73 | make clean 74 | make build 75 | 76 | %install 77 | rm -rf $RPM_BUILD_ROOT 78 | make install-vm-fedora DESTDIR=$RPM_BUILD_ROOT 79 | 80 | %clean 81 | rm -rf $RPM_BUILD_ROOT 82 | 83 | %files 84 | %defattr(-,root,root,-) 85 | %dir /usr/lib/qubes-gpg-split 86 | /usr/lib/qubes-gpg-split/gpg-server 87 | /usr/bin/qubes-gpg-client 88 | /usr/bin/qubes-gpg-client-wrapper 89 | /usr/bin/qubes-gpg-import-key 90 | /etc/qubes-rpc/qubes.Gpg 91 | /etc/qubes-rpc/qubes.GpgImportKey 92 | /etc/profile.d/qubes-gpg.sh 93 | /usr/lib/tmpfiles.d/qubes-gpg-split.conf 94 | %{_mandir}/man1/qubes-gpg-client.1* 95 | %{_mandir}/man1/qubes-gpg-client-wrapper.1* 96 | %{_mandir}/man1/qubes-gpg-import-key.1* 97 | 98 | %files tests 99 | /usr/lib/qubes-gpg-split/test_evolution.py* 100 | /usr/lib/qubes-gpg-split/test_thunderbird.py* 101 | 102 | %changelog 103 | @CHANGELOG@ 104 | -------------------------------------------------------------------------------- /src/.gitignore: -------------------------------------------------------------------------------- 1 | *.o 2 | gpg-server 3 | gpg-client 4 | -------------------------------------------------------------------------------- /src/Makefile: -------------------------------------------------------------------------------- 1 | CFLAGS+=-Wall -Wextra -Werror -O -g -fPIC -pthread 2 | LDLIBS=-pthread -fPIC 3 | 4 | all: gpg-client gpg-server 5 | 6 | gpg-client: gpg-client.o multiplex.o gpg-common.o gpg-list-options.o 7 | 8 | gpg-server: gpg-server.o multiplex.o gpg-common.o gpg-list-options.o 9 | 10 | gpg-server.o: gpg-server.c gpg-common.h multiplex.h 11 | gpg-client.o: gpg-client.c gpg-common.h multiplex.h 12 | 13 | gpg-common.o: gpg-common.c gpg-common.h 14 | gpg-list-options.o: gpg-list-options.c gpg-common.h 15 | 16 | multiplex.o: multiplex.c multiplex.h 17 | 18 | clean: 19 | rm -f ./*.o gpg-client gpg-server 20 | -------------------------------------------------------------------------------- /src/gpg-client.c: -------------------------------------------------------------------------------- 1 | #define _GNU_SOURCE 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | #include 10 | #include 11 | 12 | #include "gpg-common.h" 13 | #include "multiplex.h" 14 | 15 | #define QREXEC_CLIENT_PATH "/usr/lib/qubes/qrexec-client-vm" 16 | 17 | const bool is_client = true; 18 | 19 | int main(int argc, char *argv[]) 20 | { 21 | struct command_hdr hdr; 22 | int len, last_opt, i, add_dash_opt; 23 | int input_fds[MAX_FDS], output_fds[MAX_FDS]; 24 | int input_fds_count, output_fds_count; 25 | char *qrexec_client_path = QREXEC_CLIENT_PATH, *qcp; 26 | char *remote_domain; 27 | int pipe_in[2], pipe_out[2]; 28 | pid_t pid; 29 | 30 | remote_domain = getenv("QUBES_GPG_DOMAIN"); 31 | if (!remote_domain) { 32 | fprintf(stderr, 33 | "ERROR: Destination domain not defined! Set it with QUBES_GPG_DOMAIN env variable.\n"); 34 | exit(1); 35 | } 36 | if (!argc) 37 | errx(1, "ERROR: argc is 0"); 38 | add_dash_opt = 0; 39 | last_opt = parse_options(argc, argv, input_fds, &input_fds_count, 40 | output_fds, &output_fds_count); 41 | if (last_opt < argc) { 42 | // open the first non-option argument as stdin 43 | int input_file; 44 | 45 | if (argc - last_opt > 1) 46 | errx(1, "Too many filename arguments"); 47 | if (strcmp(argv[last_opt], "-") != 0) { 48 | /* open only when not already pointing at stdin */ 49 | input_file = open(argv[last_opt], O_RDONLY); 50 | if (input_file < 0) { 51 | perror("open"); 52 | exit(1); 53 | } 54 | dup2(input_file, 0); 55 | close(input_file); 56 | } 57 | add_dash_opt = 1; 58 | } 59 | len = 1; 60 | memset(hdr.command, 0, sizeof hdr.command); 61 | for (i = 1; i < last_opt; i++) { 62 | const size_t the_len = strlen(argv[i]) + 1; 63 | if ((size_t)COMMAND_MAX_LEN - (size_t)len < the_len) { 64 | fprintf(stderr, "ERROR: Command line too long\n"); 65 | exit(1); 66 | } else { 67 | memcpy(hdr.command + len, argv[i], the_len); 68 | len += the_len; 69 | } 70 | } 71 | if (add_dash_opt) { 72 | if (len + 2 < COMMAND_MAX_LEN) { 73 | strcpy(&hdr.command[len], "-"); 74 | len += 2; 75 | } else { 76 | fprintf(stderr, "ERROR: Command line too long\n"); 77 | exit(1); 78 | } 79 | } 80 | 81 | hdr.len = len ? len - 1 : 0; 82 | 83 | if (pipe2(pipe_in, O_CLOEXEC) || pipe2(pipe_out, O_CLOEXEC)) { 84 | perror("pipe2"); 85 | exit(1); 86 | } 87 | 88 | switch (pid = fork()) { 89 | case -1: 90 | perror("fork"); 91 | exit(1); 92 | case 0: 93 | if (dup2(pipe_in[0], 0) != 0 || dup2(pipe_out[1], 1) != 1) { 94 | perror("dup2()"); 95 | _exit(1); 96 | } 97 | qcp = getenv("QREXEC_CLIENT_PATH"); 98 | if (qcp) 99 | qrexec_client_path = qcp; 100 | execl(qrexec_client_path, "qrexec-client-vm", 101 | remote_domain, "qubes.Gpg", (char *) NULL); 102 | perror("exec"); 103 | _exit(1); 104 | } 105 | // parent 106 | if (close(pipe_in[0]) || close(pipe_out[1])) { 107 | perror("close"); 108 | exit(1); 109 | } 110 | len = write(pipe_in[1], &hdr, sizeof(hdr)); 111 | if (len != sizeof(hdr)) { 112 | perror("write header"); 113 | exit(1); 114 | } 115 | #ifdef DEBUG 116 | fprintf(stderr, "input[0]: %d, in count: %d\n", input_fds[0], 117 | input_fds_count); 118 | fprintf(stderr, "input_pipe: %d\n", input_pipe); 119 | #endif 120 | setup_sigchld(); 121 | return process_io(pipe_out[0], pipe_in[1], input_fds, 122 | input_fds_count, output_fds, output_fds_count); 123 | } 124 | -------------------------------------------------------------------------------- /src/gpg-common.c: -------------------------------------------------------------------------------- 1 | /* 2 | * The Qubes OS Project, http://www.qubes-os.org 3 | * 4 | * Copyright (C) 2010 Rafal Wojtczuk 5 | * Copyright (C) 2010 Joanna Rutkowska 6 | * Copyright (C) 2021 Demi Marie Obenour 7 | * 8 | * This program is free software; you can redistribute it and/or 9 | * modify it under the terms of the GNU General Public License 10 | * as published by the Free Software Foundation; either version 2 11 | * of the License, or (at your option) any later version. 12 | * 13 | * This program is distributed in the hope that it will be useful, 14 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | * GNU General Public License for more details. 17 | * 18 | * You should have received a copy of the GNU General Public License 19 | * along with this program; if not, write to the Free Software 20 | * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 21 | * 22 | */ 23 | #define _GNU_SOURCE 24 | #include 25 | #include 26 | #include 27 | #include 28 | #include 29 | #include 30 | #include 31 | #include 32 | #include 33 | #include 34 | 35 | 36 | #include "gpg-common.h" 37 | #include "multiplex.h" 38 | 39 | static int validate_fd_argument(const char *const untrusted_fd_arg) { 40 | if (untrusted_fd_arg == NULL || !untrusted_fd_arg[0]) 41 | goto fail; 42 | const char *untrusted_p = untrusted_fd_arg; 43 | for (; *untrusted_p; untrusted_p++) 44 | if (*untrusted_p < '0' || *untrusted_p > '9') 45 | goto fail; 46 | if (untrusted_fd_arg[0] <= '0' && untrusted_fd_arg[1]) { 47 | fprintf(stderr, "Leading zeroes in FD argument %s not allowed\n", untrusted_fd_arg); 48 | exit(1); 49 | } 50 | if (untrusted_p - untrusted_fd_arg > 4) 51 | goto too_big; 52 | errno = 0; 53 | char *endptr = NULL; 54 | long const untrusted_fd = strtol(untrusted_fd_arg, &endptr, 10); 55 | if (untrusted_fd < 0 || untrusted_fd > 9999 || errno || !endptr || *endptr) 56 | abort(); // should have been caught earlier 57 | if (untrusted_fd >= MAX_FD_VALUE) { 58 | too_big: 59 | fprintf(stderr, "FD value too big (%s > %d)\n", 60 | untrusted_fd_arg, MAX_FD_VALUE - 1); 61 | exit(1); 62 | } 63 | return (int)untrusted_fd; 64 | fail: 65 | fprintf(stderr, "Invalid fd argument. Only decimal numbers are supported.\n"); 66 | exit(1); 67 | } 68 | 69 | static void add_fd_to_list(int const untrusted_cur_fd, 70 | int *const list, int *const list_count, 71 | const int *const other_list, const int *const other_list_count) 72 | { 73 | int i, cur_fd; 74 | 75 | if (*list_count >= MAX_FDS - 1) 76 | errx(1, "Too many FDs specified (found %d, limit %d)", 77 | *list_count, MAX_FDS - 1); 78 | // check if already used for I/O in the other direction 79 | for (i = 0; i < *other_list_count; i++) { 80 | if (other_list[i] == untrusted_cur_fd) { 81 | switch (untrusted_cur_fd) { 82 | case 0: 83 | errx(1, "Cannot write to stdin"); 84 | case 1: 85 | errx(1, "Cannot read from stdout"); 86 | case 2: 87 | errx(1, "Cannot read from stderr"); 88 | default: 89 | errx(1, 90 | "Cannot use fd %d for both reading and writing", 91 | untrusted_cur_fd); 92 | } 93 | } 94 | } 95 | // check if not already in list 96 | for (i = 0; i < *list_count; i++) { 97 | if (list[i] == (int)untrusted_cur_fd) 98 | break; 99 | } 100 | cur_fd = untrusted_cur_fd; 101 | /* FD sanitization end */ 102 | if (i == *list_count) { 103 | if (is_client && cur_fd > 2 && ioctl(cur_fd, FIOCLEX)) 104 | err(1, "Cannot make file descriptor %d close-on-exec", cur_fd); 105 | list[(*list_count)++] = cur_fd; 106 | } 107 | } 108 | 109 | /* Add current argument (optarg) to given list 110 | * Check for its correctness 111 | */ 112 | static void add_arg_to_fd_list(int *list, int *list_count, 113 | const int *other_list, const int *other_list_count) 114 | { 115 | int untrusted_cur_fd = validate_fd_argument(optarg); 116 | add_fd_to_list(untrusted_cur_fd, list, list_count, other_list, other_list_count); 117 | } 118 | 119 | static void handle_opt_verify(char **untrusted_sig_path_ptr, int *input_list, int *input_list_count, 120 | const int *output_list, const int *output_list_count) 121 | { 122 | int cur_fd; 123 | 124 | if (is_client) { 125 | /* arguments on client side are trusted */ 126 | char *sig_path = *untrusted_sig_path_ptr; 127 | if (!strcmp(sig_path, "-")) 128 | cur_fd = fcntl(0, F_DUPFD_CLOEXEC, 3); 129 | else 130 | cur_fd = open(sig_path, O_RDONLY|O_CLOEXEC|O_NOCTTY); 131 | if (cur_fd < 0) 132 | err(1, "open sig file %s", sig_path); 133 | if (asprintf(untrusted_sig_path_ptr, "/dev/fd/%d", cur_fd) < 0) 134 | err(1, "asprintf"); 135 | } else { 136 | if (strncmp((*untrusted_sig_path_ptr), "/dev/fd/", 8)) 137 | errx(1, "--verify with filename allowed only on the client side"); 138 | if ((cur_fd = validate_fd_argument((*untrusted_sig_path_ptr) + 8)) < 3) 139 | errx(1, "--verify signature file descriptor must be 3 or more"); 140 | } 141 | add_fd_to_list(cur_fd, input_list, input_list_count, output_list, output_list_count); 142 | } 143 | 144 | /* This code is taken from the GUI daemon */ 145 | static int validate_utf8_char(unsigned char *untrusted_c) { 146 | int tails_count = 0; 147 | int total_size = 0; 148 | /* it is safe to access byte pointed by the parameter and the next one 149 | * (which can be terminating NULL), but every next byte can access only if 150 | * neither of previous bytes was NULL 151 | */ 152 | 153 | /* According to http://www.ietf.org/rfc/rfc3629.txt: 154 | * UTF8-char = UTF8-1 / UTF8-2 / UTF8-3 / UTF8-4 155 | * UTF8-1 = %x00-7F 156 | * UTF8-2 = %xC2-DF UTF8-tail 157 | * UTF8-3 = %xE0 %xA0-BF UTF8-tail / %xE1-EC 2( UTF8-tail ) / 158 | * %xED %x80-9F UTF8-tail / %xEE-EF 2( UTF8-tail ) 159 | * UTF8-4 = %xF0 %x90-BF 2( UTF8-tail ) / %xF1-F3 3( UTF8-tail ) / 160 | * %xF4 %x80-8F 2( UTF8-tail ) 161 | * UTF8-tail = %x80-BF 162 | */ 163 | 164 | if (*untrusted_c <= 0x7F) { 165 | return 1; 166 | } else if (*untrusted_c >= 0xC2 && *untrusted_c <= 0xDF) { 167 | total_size = 2; 168 | tails_count = 1; 169 | } else switch (*untrusted_c) { 170 | case 0xE0: 171 | untrusted_c++; 172 | total_size = 3; 173 | if (*untrusted_c >= 0xA0 && *untrusted_c <= 0xBF) 174 | tails_count = 1; 175 | else 176 | return 0; 177 | break; 178 | case 0xE1: case 0xE2: case 0xE3: case 0xE4: 179 | case 0xE5: case 0xE6: case 0xE7: case 0xE8: 180 | case 0xE9: case 0xEA: case 0xEB: case 0xEC: 181 | /* 0xED */ 182 | case 0xEE: 183 | case 0xEF: 184 | total_size = 3; 185 | tails_count = 2; 186 | break; 187 | case 0xED: 188 | untrusted_c++; 189 | total_size = 3; 190 | if (*untrusted_c >= 0x80 && *untrusted_c <= 0x9F) 191 | tails_count = 1; 192 | else 193 | return 0; 194 | break; 195 | case 0xF0: 196 | untrusted_c++; 197 | total_size = 4; 198 | if (*untrusted_c >= 0x90 && *untrusted_c <= 0xBF) 199 | tails_count = 2; 200 | else 201 | return 0; 202 | break; 203 | case 0xF1: 204 | case 0xF2: 205 | case 0xF3: 206 | total_size = 4; 207 | tails_count = 3; 208 | break; 209 | case 0xF4: 210 | untrusted_c++; 211 | if (*untrusted_c >= 0x80 && *untrusted_c <= 0x8F) 212 | tails_count = 2; 213 | else 214 | return 0; 215 | break; 216 | default: 217 | return 0; 218 | } 219 | 220 | while (tails_count-- > 0) { 221 | untrusted_c++; 222 | if (!(*untrusted_c >= 0x80 && *untrusted_c <= 0xBF)) 223 | return 0; 224 | } 225 | return total_size; 226 | } 227 | 228 | /* Validate that the given string (which must be NUL-terminated) is 229 | * printable UTF-8 */ 230 | static void sanitize_string_from_vm(unsigned char *untrusted_s) 231 | { 232 | int utf8_ret; 233 | for (; *untrusted_s; untrusted_s++) { 234 | // allow only non-control ASCII chars 235 | if (*untrusted_s >= 0x20 && *untrusted_s <= 0x7E) 236 | continue; 237 | if (*untrusted_s >= 0x80) { 238 | utf8_ret = validate_utf8_char(untrusted_s); 239 | if (utf8_ret > 0) { 240 | /* loop will do one additional increment */ 241 | untrusted_s += utf8_ret - 1; 242 | continue; 243 | } 244 | } 245 | fputs("Command line arguments must be printable UTF-8, sorry\n", stderr); 246 | exit(1); 247 | } 248 | } 249 | 250 | int parse_options(int argc, char *untrusted_argv[], int *input_fds, 251 | int *input_fds_count, int *output_fds, 252 | int *output_fds_count) 253 | { 254 | int opt, command = 0; 255 | int longindex; 256 | int i, ok; 257 | bool userid_args = false, mode_verify = false; 258 | bool seen_status_fd = false, seen_logger_fd = false, seen_attribute_fd = false; 259 | char *lastarg = NULL; 260 | static struct listopt const allowed_list_options[] = { 261 | // potential information leak 262 | { "show-keyring", false, true, false }, 263 | { "show-keyserver-urls", true, true, false }, 264 | { "show-notations", true, true, false }, 265 | { "show-photos", true, true, false }, 266 | { "show-policy-urls", true, true, false }, 267 | { "show-sig-expire", true, true, false }, 268 | { "show-std-notations", true, true, false }, 269 | { "show-standard-notations", true, true, false }, 270 | { "show-uid-validity", true, true, false }, 271 | { "show-unusable-uids", true, true, false }, 272 | { "show-unusable-subkeys", true, true, false }, 273 | { "show-usage", true, true, false }, 274 | { "show-user-notations", true, true, false }, 275 | { "show-sig-subpackets", true, true, true }, 276 | { "show-only-fpr-mbox", true, true, false }, 277 | { "sort-sigs", true, true, false }, 278 | { NULL, false, false, false }, 279 | }; 280 | static struct listopt const allowed_verify_options[] = { 281 | { "show-keyserver-urls", true, true, false }, 282 | { "show-notations", true, true, false }, 283 | { "show-photos", true, true, false }, 284 | { "show-policy-urls", true, true, false }, 285 | { "show-std-notations", true, true, false }, 286 | { "show-standard-notations", true, true, false }, 287 | { "show-uid-validity", true, true, false }, 288 | { "show-unusable-uids", true, true, false }, 289 | { "show-user-notations", true, true, false }, 290 | { "show-primary-key-only", true, true, false }, 291 | { NULL, false, false, false }, 292 | }; 293 | static struct listopt const allowed_export_options[] = { 294 | { "export-local-sigs", true, true, false }, 295 | { "export-attributes", true, true, false }, 296 | { "export-sensitive-revkeys", true, true, false }, 297 | { "export-clean", true, true, false }, 298 | { "export-minimal", true, true, false }, 299 | { "export-dane", false, true, false }, 300 | { "backup", true, true, false }, 301 | { "include-local-sigs", true, true, false }, 302 | { "include-attributes", true, true, false }, 303 | { "include-sensitive-revkeys", true, true, false }, 304 | { "export-unusable-sigs", true, true, false }, 305 | { "export-clean-sigs", true, true, false }, 306 | { "export-clean-uids", true, true, false }, 307 | { NULL, false, false, false }, 308 | }; 309 | 310 | *input_fds_count = 0; 311 | *output_fds_count = 0; 312 | 313 | // Do not print error messages on the server side. The client side should 314 | // have already printed an error, so the error-message generation code is 315 | // useless attack surface. 316 | if (!is_client) 317 | opterr = 0; 318 | 319 | // Standard FDs 320 | input_fds[(*input_fds_count)++] = 0; //stdin 321 | output_fds[(*output_fds_count)++] = 1; //stdout 322 | output_fds[(*output_fds_count)++] = 2; //stderr 323 | if (ioctl(2, is_client ? FIONCLEX : FIOCLEX) || 324 | ioctl(0, FIOCLEX) || ioctl(1, FIOCLEX)) 325 | errx(1, "File descriptor 0, 1, or 2 is bad"); 326 | 327 | for (int i = 0; i < argc; ++i) { 328 | if (!untrusted_argv[i]) 329 | abort(); 330 | sanitize_string_from_vm((unsigned char *)(untrusted_argv[i])); 331 | } 332 | if (untrusted_argv[argc]) 333 | abort(); 334 | 335 | /* getopt will filter out not allowed options */ 336 | while ((void)(longindex = -1), 337 | (void)(lastarg = (optind <= argc ? untrusted_argv[optind] : NULL)), 338 | (opt = getopt_long(argc, untrusted_argv, gpg_short_options, 339 | gpg_long_options, &longindex)) != -1) { 340 | if (opt == '?' || opt == ':') { 341 | /* forbidden/missing option - abort execution */ 342 | //error message already printed by getopt 343 | exit(1); 344 | } 345 | i = 0; 346 | ok = 0; 347 | if (!lastarg) 348 | abort(); 349 | // Number of distinct long options 350 | static const int opts = (int)(sizeof(gpg_long_options)/sizeof(gpg_long_options[0])) - 1; 351 | if (lastarg[0] == '-' && lastarg[1] == '-') { 352 | assert(longindex >= 0 && longindex < opts); 353 | const char *const optname = gpg_long_options[longindex].name; 354 | const size_t len = strlen(optname); 355 | const char *const optval = lastarg + 2; 356 | const char *const res = strchr(optval, '='); 357 | const size_t delta = res ? (size_t)(res - optval) : strlen(optval); 358 | if (delta > len || memcmp(optname, optval, delta)) { 359 | fprintf(stderr, 360 | "split-gpg: internal error: option misparsed by getopt_long(3)\n"); 361 | abort(); 362 | } 363 | if (delta < len) { 364 | fprintf(stderr, 365 | "Abbreviated option '--%.*s' must be written as '--%s'\n", 366 | (int)delta, optname, optname); 367 | exit(1); 368 | } 369 | } else { 370 | assert(longindex == -1); 371 | } 372 | while (gpg_allowed_options[i]) { 373 | if (gpg_allowed_options[i] == opt) { 374 | ok = 1; 375 | break; 376 | } 377 | i++; 378 | } 379 | if (!ok) { 380 | if (longindex != -1) 381 | fprintf(stderr, "Forbidden option: --%s\n", 382 | gpg_long_options[longindex].name); 383 | else 384 | fprintf(stderr, "Forbidden option: -%c\n", opt); 385 | exit(1); 386 | } 387 | i = 0; 388 | while (gpg_commands[i].opt) { 389 | if (gpg_commands[i].opt == opt) { 390 | if (command && userid_args != gpg_commands[i].userid_args) { 391 | /* gpg gives similarly vague error message */ 392 | fprintf(stderr, "conflicting commands\n"); 393 | exit(1); 394 | } 395 | command = opt; 396 | userid_args = gpg_commands[i].userid_args; 397 | break; 398 | } 399 | i++; 400 | } 401 | if (opt == opt_status_fd) { 402 | if (seen_status_fd) 403 | errx(1, "--status-fd can only be specified once"); 404 | seen_status_fd = true; 405 | add_arg_to_fd_list(output_fds, output_fds_count, input_fds, input_fds_count); 406 | } else if (opt == opt_logger_fd) { 407 | if (seen_logger_fd) 408 | errx(1, "--logger-fd can only be specified once"); 409 | seen_logger_fd = true; 410 | add_arg_to_fd_list(output_fds, output_fds_count, input_fds, input_fds_count); 411 | } else if (opt == opt_attribute_fd) { 412 | if (seen_attribute_fd) 413 | errx(1, "--attribute-fd can only be specified once"); 414 | seen_attribute_fd = true; 415 | add_arg_to_fd_list(output_fds, output_fds_count, input_fds, input_fds_count); 416 | } else if (opt == opt_verify) { 417 | mode_verify = 1; 418 | } else if (opt == 'o') { 419 | if (strcmp(optarg, "-") != 0) { 420 | fprintf(stderr, "Only '-' argument supported for --output option\n"); 421 | exit(1); 422 | } 423 | } else if (opt == opt_list_options) { 424 | sanitize_option_list(allowed_list_options, "list"); 425 | } else if (opt == opt_verify_options) { 426 | sanitize_option_list(allowed_verify_options, "verify"); 427 | } else if (opt == opt_export_options) { 428 | sanitize_option_list(allowed_export_options, "export"); 429 | } 430 | } 431 | // Only allow key IDs to begin with '-' if the options list was terminated by '--', 432 | // or if the argument is a literal "-" (which is never considered an option) 433 | if (!lastarg || strcmp(lastarg, "--")) { 434 | for (int i = optind; i < argc; ++i) { 435 | const char *const untrusted_arg = untrusted_argv[i]; 436 | if (untrusted_arg[0] == '-' && untrusted_arg[1]) { 437 | fprintf(stderr, "Non-option arguments must not start with '-', unless preceeded by \"--\"\n" 438 | "to mark the end of options. " 439 | "As an exception, the literal string \"-\" of length 1 is allowed.\n"); 440 | exit(1); 441 | } 442 | } 443 | } 444 | if (userid_args) { 445 | // all the arguments are key IDs/user IDs, so do not try to handle them 446 | // as input files 447 | optind = argc; 448 | } 449 | if (mode_verify && optind < argc) { 450 | handle_opt_verify(untrusted_argv + optind, 451 | input_fds, input_fds_count, 452 | output_fds, output_fds_count); 453 | /* the first path already processed */ 454 | optind++; 455 | } 456 | 457 | return optind; 458 | } 459 | 460 | void move_fds(const int *const dest_fds, int const count, int (*const pipes)[2], 461 | _Bool pipe_end) 462 | { 463 | _Static_assert(MAX_FDS > 0 && MAX_FDS < MAX_FD_VALUE, "bad constants"); 464 | assert(count >= 0 && count <= MAX_FDS); 465 | 466 | 467 | // move pipes to correct fds 468 | for (int i = 0; i < count; i++) 469 | if (dup2(pipes[i][pipe_end], dest_fds[i]) != dest_fds[i]) 470 | _exit(1); 471 | // no need to close the pipe fds as they are CLOEXEC and we exec later 472 | } 473 | 474 | static void dup_over_fd(int const fallback_fd, int const fd) { 475 | if (fallback_fd < 0 || fd < 0) 476 | abort(); 477 | if (fcntl(fd, F_GETFD) == -1) { 478 | assert(errno == EBADF); 479 | int const new_fd = fcntl(fallback_fd, F_DUPFD_CLOEXEC, fd); 480 | if (new_fd != fd) { 481 | assert(new_fd == -1 && "F_DUPFD_CLOEXEC set file descriptor to bad value?"); 482 | perror("dup2"); 483 | exit(1); 484 | } 485 | } 486 | } 487 | 488 | int prepare_pipes_and_run(const char *run_file, char **run_argv, int *input_fds, 489 | int input_fds_count, int *output_fds, 490 | int output_fds_count) 491 | { 492 | int i, null_fd; 493 | pid_t pid; 494 | int pipes_in[MAX_FDS][2]; 495 | int pipes_out[MAX_FDS][2]; 496 | int pipes_in_for_multiplexer[MAX_FDS]; 497 | int pipes_out_for_multiplexer[MAX_FDS]; 498 | sigset_t chld_set; 499 | 500 | sigemptyset(&chld_set); 501 | sigaddset(&chld_set, SIGCHLD); 502 | if (input_fds_count > MAX_FDS || output_fds_count > MAX_FDS) 503 | abort(); 504 | null_fd = open("/dev/null", O_RDONLY | O_NOCTTY | O_CLOEXEC | O_NOFOLLOW); 505 | if (null_fd < 0) 506 | err(1, "open /dev/null"); 507 | for (i = 0; i < input_fds_count; ++i) 508 | dup_over_fd(null_fd, input_fds[i]); 509 | for (i = 0; i < output_fds_count; ++i) 510 | dup_over_fd(null_fd, output_fds[i]); 511 | // do not close null_fd yet; it could be one of the file descriptors 512 | // to pass to GnuPG 513 | for (i = 0; i < input_fds_count; i++) { 514 | if (pipe2(pipes_in[i], O_CLOEXEC) < 0) { 515 | perror("pipe"); 516 | exit(1); 517 | } 518 | // multiplexer writes to gpg through this fd 519 | pipes_in_for_multiplexer[i] = pipes_in[i][1]; 520 | } 521 | for (i = 0; i < output_fds_count; i++) { 522 | if (pipe2(pipes_out[i], O_CLOEXEC) < 0) { 523 | perror("pipe"); 524 | exit(1); 525 | } 526 | // multiplexer reads from gpg through this fd 527 | pipes_out_for_multiplexer[i] = pipes_out[i][0]; 528 | } 529 | // now that the pipes are created, null_fd can be closed safely 530 | close(null_fd); 531 | 532 | setup_sigchld(); 533 | 534 | switch (pid = fork()) { 535 | case -1: 536 | perror("fork"); 537 | exit(1); 538 | case 0: 539 | // child 540 | move_fds(input_fds, input_fds_count, pipes_in, 0); 541 | move_fds(output_fds, output_fds_count, pipes_out, 1); 542 | execv(run_file, run_argv); 543 | _exit(1); 544 | default: 545 | // close unneded end of pipes 546 | for (i = 0; i < input_fds_count; i++) 547 | close(pipes_in[i][0]); 548 | for (i = 0; i < output_fds_count; i++) 549 | close(pipes_out[i][1]); 550 | sigprocmask(SIG_BLOCK, &chld_set, NULL); 551 | return process_io(0, 1, pipes_out_for_multiplexer, 552 | output_fds_count, 553 | pipes_in_for_multiplexer, 554 | input_fds_count); 555 | } 556 | assert(0); 557 | } 558 | -------------------------------------------------------------------------------- /src/gpg-common.h: -------------------------------------------------------------------------------- 1 | #ifndef _GPG_H 2 | #define _GPG_H 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | #define COMMAND_MAX_LEN 1024 11 | 12 | struct command_hdr { 13 | uint32_t len; 14 | char command[COMMAND_MAX_LEN]; 15 | }; 16 | 17 | #define MAX_FDS 16 18 | #define MAX_FD_VALUE 1024 19 | 20 | enum { 21 | opt_always_trust = 257, 22 | opt_attribute_fd, 23 | opt_batch, 24 | opt_cert_digest_algo, 25 | opt_cert_notation, 26 | opt_cipher_algo, 27 | opt_clearsign, 28 | opt_clear_sign, 29 | opt_command_fd, 30 | opt_comment, 31 | opt_compress_algo, 32 | opt_default_recipient, 33 | opt_default_recipient_self, 34 | opt_digest_algo, 35 | opt_disable_cipher_algo, 36 | opt_disable_mdc, 37 | opt_disable_pubkey_algo, 38 | opt_display, 39 | opt_display_charset, 40 | opt_emit_version, 41 | opt_enable_progress_filter, 42 | opt_encrypt_to, 43 | opt_exit_on_status_write_error, 44 | opt_export, 45 | opt_export_options, 46 | opt_export_ownertrust, 47 | opt_export_ssh, 48 | opt_fingerprint, 49 | opt_fixed_list_mode, 50 | opt_force_mdc, 51 | opt_force_v3_sigs, 52 | opt_force_v4_certs, 53 | opt_gnupg, 54 | opt_hidden_encrypt_to, 55 | opt_list_config, 56 | opt_list_only, 57 | opt_list_options, 58 | opt_list_sigs, 59 | opt_logger_fd, 60 | opt_keyid_format, 61 | opt_max_output, 62 | opt_no_auto_check_trustdb, 63 | opt_no_comments, 64 | opt_no_default_recipient, 65 | opt_no_emit_version, 66 | opt_no_encrypt_to, 67 | opt_no_force_v3_sigs, 68 | opt_no_force_v4_certs, 69 | opt_no_greeting, 70 | opt_no_secmem_warning, 71 | opt_no_sk_comments, 72 | opt_no_skip_hidden_recipients, 73 | opt_no_throw_keyids, 74 | opt_no_tty, 75 | opt_no_verbose, 76 | opt_openpgp, 77 | opt_personal_cipher_preferences, 78 | opt_personal_compress_preferences, 79 | opt_personal_digest_preferences, 80 | opt_pgp6, 81 | opt_pgp7, 82 | opt_pgp8, 83 | opt_rfc2440, 84 | opt_rfc4880, 85 | opt_s2k_cipher_algo, 86 | opt_s2k_count, 87 | opt_s2k_digest_algo, 88 | opt_s2k_mode, 89 | opt_sender, 90 | opt_set_filename, 91 | opt_show_session_key, 92 | opt_sig_notation, 93 | opt_skip_hidden_recipients, 94 | opt_status_fd, 95 | opt_store, 96 | opt_throw_keyids, 97 | opt_trust_model, 98 | opt_trusted_key, 99 | opt_try_all_secrets, 100 | opt_try_secret_key, 101 | opt_unwrap, 102 | opt_use_agent, 103 | opt_utf8_strings, 104 | opt_verify, 105 | opt_verify_options, 106 | opt_version, 107 | opt_with_colons, 108 | opt_with_fingerprint, 109 | opt_with_keygrip, 110 | opt_with_secret, 111 | }; 112 | 113 | int parse_options(int argc, char *argv[], int *input_fds, 114 | int *input_fds_count, int *output_fds, 115 | int *output_fds_count); 116 | int prepare_pipes_and_run(const char *run_file, char **run_argv, int *input_fds, 117 | int input_fds_count, int *output_fds, 118 | int output_fds_count); 119 | 120 | static const int gpg_allowed_options[] = { 121 | 'b', 122 | 'a', 123 | 'c', 124 | 'd', 125 | 'e', 126 | 'k', 127 | 'K', 128 | 'n', 129 | 'N', 130 | 'o', 131 | 'q', 132 | 'r', 133 | 'R', 134 | 's', 135 | 't', 136 | 'u', 137 | 'v', 138 | opt_always_trust, 139 | opt_attribute_fd, 140 | opt_batch, 141 | opt_cert_notation, 142 | opt_clearsign, 143 | opt_clear_sign, 144 | opt_comment, 145 | opt_compress_algo, 146 | opt_default_recipient_self, 147 | opt_digest_algo, 148 | opt_emit_version, 149 | opt_enable_progress_filter, 150 | opt_encrypt_to, 151 | opt_exit_on_status_write_error, 152 | opt_export, 153 | opt_export_options, 154 | opt_export_ownertrust, 155 | opt_export_ssh, 156 | opt_fingerprint, 157 | opt_fixed_list_mode, 158 | opt_gnupg, 159 | opt_hidden_encrypt_to, 160 | opt_list_config, 161 | opt_list_only, 162 | opt_list_options, 163 | opt_list_sigs, 164 | opt_logger_fd, 165 | opt_keyid_format, 166 | opt_max_output, 167 | opt_no_auto_check_trustdb, 168 | opt_no_comments, 169 | opt_no_default_recipient, 170 | opt_no_emit_version, 171 | opt_no_encrypt_to, 172 | opt_no_force_v3_sigs, 173 | opt_no_force_v4_certs, 174 | opt_no_greeting, 175 | opt_no_secmem_warning, 176 | opt_no_skip_hidden_recipients, 177 | opt_no_throw_keyids, 178 | opt_no_verbose, 179 | opt_openpgp, 180 | opt_personal_cipher_preferences, 181 | opt_personal_compress_preferences, 182 | opt_personal_digest_preferences, 183 | opt_pgp6, 184 | opt_pgp7, 185 | opt_pgp8, 186 | opt_rfc2440, 187 | opt_rfc4880, 188 | opt_s2k_cipher_algo, 189 | opt_s2k_count, 190 | opt_s2k_digest_algo, 191 | opt_s2k_mode, 192 | opt_sender, 193 | opt_set_filename, 194 | opt_show_session_key, 195 | opt_sig_notation, 196 | opt_skip_hidden_recipients, 197 | opt_status_fd, 198 | opt_store, 199 | opt_throw_keyids, 200 | opt_trust_model, 201 | opt_trusted_key, 202 | opt_try_all_secrets, 203 | opt_try_secret_key, 204 | opt_unwrap, 205 | opt_verify, 206 | opt_verify_options, 207 | opt_version, 208 | opt_with_colons, 209 | opt_with_fingerprint, 210 | opt_with_keygrip, 211 | opt_with_secret, 212 | 0 213 | }; 214 | 215 | /* 216 | * Options that define command to perform. There can be only one of those, and 217 | * the command define how non-option arguments are interpreted (either file 218 | * path, or user id). 219 | */ 220 | struct gpg_command_opt { 221 | int opt; 222 | bool userid_args; 223 | }; 224 | static const struct gpg_command_opt gpg_commands[] = { 225 | {'K', true}, 226 | {'b', false}, 227 | {'c', false}, 228 | {'d', false}, 229 | {'e', false}, 230 | {'k', true}, 231 | {'s', false}, 232 | {opt_clearsign, false}, 233 | {opt_clear_sign, false}, 234 | {opt_export, true}, 235 | {opt_export_ssh, true}, 236 | {opt_export_ownertrust, false}, 237 | {opt_fingerprint, true}, 238 | {opt_list_config, false}, 239 | {opt_list_sigs, true}, 240 | {opt_store, false}, 241 | {opt_verify, false}, 242 | {0, false}, 243 | }; 244 | 245 | static const char gpg_short_options[] = "+bacdekKnN:o:qr:R:stu:"; 246 | 247 | static const struct option gpg_long_options[] = { 248 | {"always-trust", 0, 0, opt_always_trust}, 249 | {"armor", 0, 0, 'a'}, 250 | {"attribute-fd", 1, 0, opt_attribute_fd}, 251 | {"batch", 0, 0, opt_batch}, 252 | {"cert-digest-algo", 1, 0, opt_cert_digest_algo}, 253 | {"cert-notation", 1, 0, opt_cert_notation}, 254 | {"charset", 1, 0, opt_display_charset}, 255 | {"cipher-algo", 1, 0, opt_cipher_algo}, 256 | {"clearsign", 0, 0, opt_clearsign}, 257 | {"clear-sign", 0, 0, opt_clear_sign}, 258 | {"command-fd", 1, 0, opt_command_fd}, 259 | {"comment", 1, 0, opt_comment}, 260 | {"compress-algo", 1, 0, opt_compress_algo}, 261 | {"decrypt", 0, 0, 'd'}, 262 | {"default-recipient", 1, 0, opt_default_recipient}, 263 | {"default-recipient-self", 0, 0, opt_default_recipient_self}, 264 | {"detach-sign", 0, 0, 'b'}, 265 | {"digest-algo", 1, 0, opt_digest_algo}, 266 | {"disable-cipher-algo", 1, 0, opt_disable_cipher_algo}, 267 | {"disable-mdc", 0, 0, opt_disable_mdc}, 268 | {"disable-pubkey-algo", 1, 0, opt_disable_pubkey_algo}, 269 | {"display", 1, 0, opt_display}, 270 | {"display-charset", 1, 0, opt_display_charset}, 271 | {"dry-run", 0, 0, 'n'}, 272 | {"emit-version", 0, 0, opt_emit_version}, 273 | {"enable-progress-filter", 0, 0, opt_enable_progress_filter}, 274 | {"encrypt", 0, 0, 'e'}, 275 | {"encrypt-to", 1, 0, opt_encrypt_to}, 276 | {"exit-on-status-write-error", 0, 0, opt_exit_on_status_write_error}, 277 | {"export", 0, 0, opt_export}, 278 | {"export-ssh-key", 0, 0, opt_export_ssh}, 279 | {"export-options", 1, 0, opt_export_options}, 280 | {"export-ownertrust", 0, 0, opt_export_ownertrust}, 281 | {"fingerprint", 0, 0, opt_fingerprint}, 282 | {"fixed-list-mode", 0, 0, opt_fixed_list_mode}, 283 | {"force-mdc", 0, 0, opt_force_mdc}, 284 | {"force-v3-sigs", 0, 0, opt_force_v3_sigs}, 285 | {"force-v4-certs", 0, 0, opt_force_v4_certs}, 286 | {"gnupg", 0, 0, opt_gnupg}, 287 | {"hidden-encrypt-to", 1, 0, opt_hidden_encrypt_to}, 288 | {"hidden-recipient", 1, 0, 'R'}, 289 | {"list-config", 0, 0, opt_list_config}, 290 | {"list-key", 0, 0, 'k'}, 291 | {"list-keys", 0, 0, 'k'}, 292 | {"list-only", 0, 0, opt_list_only}, 293 | {"list-options", 1, 0, opt_list_options}, 294 | {"list-public-keys", 0, 0, 'k'}, 295 | {"list-secret-keys", 0, 0, 'K'}, 296 | {"list-sig", 0, 0, opt_list_sigs}, 297 | {"list-sigs", 0, 0, opt_list_sigs}, 298 | {"local-user", 1, 0, 'u'}, 299 | {"logger-fd", 1, 0, opt_logger_fd}, 300 | {"keyid-format", 1, 0, opt_keyid_format}, 301 | {"max-output", 1, 0, opt_max_output}, 302 | {"no-auto-check-trustdb", 0, 0, opt_no_auto_check_trustdb}, 303 | {"no-comments", 0, 0, opt_no_comments}, 304 | {"no-default-recipient", 0, 0, opt_no_default_recipient}, 305 | {"no-encrypt-to", 0, 0, opt_no_encrypt_to}, 306 | {"no-emit-version", 0, 0, opt_no_emit_version}, 307 | {"no-force-v3-sigs", 0, 0, opt_no_force_v3_sigs}, 308 | {"no-force-v4-certs", 0, 0, opt_no_force_v4_certs}, 309 | {"no-greeting", 0, 0, opt_no_greeting}, 310 | {"no-secmem-warning", 0, 0, opt_no_secmem_warning}, 311 | {"no-sk-comments", 0, 0, opt_no_sk_comments}, 312 | {"no-skip-hidden-recipients", 0, 0, opt_no_skip_hidden_recipients}, 313 | {"no-throw-keyids", 0, 0, opt_no_throw_keyids}, 314 | {"no-tty", 0, 0, opt_no_tty}, 315 | {"no-verbose", 0, 0, opt_no_verbose}, 316 | {"openpgp", 0, 0, opt_openpgp}, 317 | {"output", 1, 0, 'o'}, 318 | {"personal-cipher-preferences", 1, 0, opt_personal_cipher_preferences}, 319 | {"personal-compress-preferences", 1, 0, opt_personal_compress_preferences}, 320 | {"personal-digest-preferences", 1, 0, opt_personal_digest_preferences}, 321 | {"pgp6", 0, 0, opt_pgp6}, 322 | {"pgp7", 0, 0, opt_pgp7}, 323 | {"pgp8", 0, 0, opt_pgp8}, 324 | {"quiet", 0, 0, 'q'}, 325 | {"recipient", 1, 0, 'r'}, 326 | {"rfc2440", 0, 0, opt_rfc2440}, 327 | {"rfc4880", 0, 0, opt_rfc4880}, 328 | {"s2k-cipher-algo", 1, 0, opt_s2k_cipher_algo}, 329 | {"s2k-count", 1, 0, opt_s2k_count}, 330 | {"s2k-digest-algo", 1, 0, opt_s2k_digest_algo}, 331 | {"s2k-mode", 1, 0, opt_s2k_mode}, 332 | {"show-session-key", 0, 0, opt_show_session_key}, 333 | {"sender", 1, 0, opt_sender}, 334 | {"sig-notation", 1, 0, opt_sig_notation}, 335 | {"sign", 0, 0, 's'}, 336 | {"set-filename", 1, 0, opt_set_filename}, 337 | {"set-notation", 1, 0, 'N'}, 338 | {"skip-hidden-recipients", 0, 0, opt_skip_hidden_recipients}, 339 | {"status-fd", 1, 0, opt_status_fd}, 340 | {"store", 0, 0, opt_store}, 341 | {"symmetric", 0, 0, 'c'}, 342 | {"textmode", 0, 0, 't'}, 343 | {"throw-keyids", 0, 0, opt_throw_keyids}, 344 | {"trust-model", 1, 0, opt_trust_model}, 345 | {"trusted-key", 1, 0, opt_trusted_key}, 346 | {"try-secret-key", 1, 0, opt_try_secret_key}, 347 | {"try-all-secrets", 0, 0, opt_try_all_secrets}, 348 | {"unwrap", 0, 0, opt_unwrap}, 349 | {"use-agent", 0, 0, opt_use_agent}, 350 | {"utf8-strings", 0, 0, opt_utf8_strings}, 351 | {"verify", 0, 0, opt_verify}, 352 | {"verify-options", 1, 0, opt_verify_options}, 353 | {"verbose", 0, 0, 'v'}, 354 | {"version", 0, 0, opt_version}, 355 | {"with-colons", 0, 0, opt_with_colons}, 356 | {"with-fingerprint", 0, 0, opt_with_fingerprint}, 357 | {"with-keygrip", 0, 0, opt_with_keygrip}, 358 | {"with-secret", 0, 0, opt_with_secret}, 359 | {0, 0, 0, 0} 360 | }; 361 | 362 | struct listopt { 363 | const char *const name; 364 | bool const allowed, allowed_negated, has_argument; 365 | }; 366 | 367 | void sanitize_option_list(const struct listopt *p, const char *msg); 368 | 369 | extern const bool is_client; 370 | 371 | #endif /* _GPG_H */ 372 | -------------------------------------------------------------------------------- /src/gpg-list-options.c: -------------------------------------------------------------------------------- 1 | #include "gpg-common.h" 2 | #include 3 | #include 4 | #include 5 | #include 6 | 7 | struct lexbuf { 8 | uint8_t *untrusted_cursor; 9 | }; 10 | 11 | static bool buf_consume(struct lexbuf *const buf, uint8_t const expected) 12 | { 13 | bool ret = buf->untrusted_cursor[0] == expected; 14 | buf->untrusted_cursor += ret; 15 | return ret; 16 | } 17 | 18 | static bool is_delim(uint8_t const untrusted_c) 19 | { 20 | return untrusted_c == ' ' || untrusted_c == ','; 21 | } 22 | 23 | /* Skip spaces and commas */ 24 | static void buf_consume_delims(struct lexbuf *const buf) 25 | { 26 | while (is_delim(buf->untrusted_cursor[0])) 27 | buf->untrusted_cursor++; 28 | } 29 | 30 | static void consume_subpacket_number(struct lexbuf *const buf) 31 | { 32 | char *endptr = NULL; 33 | long untrusted_subpacket_number = strtol((const char *)buf->untrusted_cursor, &endptr, 10); 34 | if (endptr == NULL) 35 | abort(); 36 | if ((const uint8_t *)endptr == buf->untrusted_cursor) 37 | errx(1, "Invalid character in subpacket number list"); 38 | if (untrusted_subpacket_number < 1 || untrusted_subpacket_number > 127) 39 | errx(1, "Subpacket number not valid (must be between 1 and 127 inclusive, got %ld)", 40 | untrusted_subpacket_number); 41 | switch (*endptr) { 42 | case '"': 43 | case ',': 44 | case ' ': 45 | case '\0': 46 | buf->untrusted_cursor = (uint8_t *)endptr; 47 | return; 48 | default: 49 | errx(1, "Invalid character %c following subpacket number", *endptr); 50 | } 51 | } 52 | 53 | static void consume_subpackets_argument(struct lexbuf *const buf) 54 | { 55 | if (buf_consume(buf, '"')) { 56 | if (!strchr((const char *)buf->untrusted_cursor, '"')) 57 | errx(1, "Unterminated quoted option argument"); 58 | buf_consume_delims(buf); 59 | while (!buf_consume(buf, '"')) { 60 | consume_subpacket_number(buf); 61 | buf_consume_delims(buf); 62 | } 63 | } else { 64 | consume_subpacket_number(buf); 65 | } 66 | } 67 | 68 | static size_t read_option(uint8_t *untrusted_option) { 69 | for (size_t i = 0; ; ++i) { 70 | switch (untrusted_option[i]) { 71 | case '=': 72 | case ',': 73 | case ' ': 74 | case '\0': 75 | return i; 76 | case 'A'...'Z': // GnuPG is case-insensitive, but this code is case-sensitive; casefold 77 | untrusted_option[i] |= 0x20; 78 | break; 79 | default: 80 | break; 81 | } 82 | } 83 | } 84 | 85 | static const struct listopt *find_option(const uint8_t *const untrusted_option, 86 | size_t const len, 87 | const struct listopt *options, 88 | const char *const msg) 89 | { 90 | for (; options->name; ++options) { 91 | if (strlen(options->name) == len && 92 | memcmp(options->name, untrusted_option, len) == 0) 93 | break; 94 | } 95 | if (!options->name) 96 | errx(1, "Unknown %s option %.*s", msg, (int)len, untrusted_option); 97 | return options; 98 | } 99 | 100 | void sanitize_option_list(const struct listopt *allowed_list_options, const char *const msg) 101 | { 102 | if (!strcmp(optarg, "help")) 103 | return; // allow --list-options=help 104 | struct lexbuf buf_ = { .untrusted_cursor = (uint8_t *)optarg }; 105 | struct lexbuf *const buf = &buf_; 106 | 107 | for (const char *untrusted_c = optarg; *untrusted_c; untrusted_c++) { 108 | /* char is signed on x86, so the first check is not redundant */ 109 | if (*untrusted_c < 0x20 || *untrusted_c > 0x7E) 110 | errx(1, "Non-ASCII byte %" PRIu8 " forbidden in %s option", (uint8_t)*untrusted_c, msg); 111 | } 112 | 113 | for (;;) { 114 | /* Skip leading spaces and commas */ 115 | buf_consume_delims(buf); 116 | if (buf->untrusted_cursor[0] == '\0') 117 | return; /* Nothing to do */ 118 | 119 | /* Read the identifier */ 120 | uint8_t *const untrusted_option = buf->untrusted_cursor; 121 | size_t const option_len = read_option(untrusted_option); 122 | if (option_len == 0) 123 | errx(1, "'=' not following a %s option", msg); 124 | 125 | bool const negated = option_len >= 3 && 126 | memcmp(untrusted_option, "no-", 3) == 0; 127 | const struct listopt *const p = negated ? 128 | find_option(untrusted_option + 3, option_len - 3, 129 | allowed_list_options, msg) : 130 | find_option(untrusted_option, option_len, 131 | allowed_list_options, msg); 132 | 133 | if (!(negated ? p->allowed_negated : p->allowed)) 134 | errx(1, "Forbidden %s option %.*s", msg, 135 | (int)option_len, untrusted_option); 136 | 137 | buf->untrusted_cursor += option_len; 138 | 139 | while (buf_consume(buf, ' ')) {} 140 | 141 | if (!buf_consume(buf, '=')) 142 | continue; /* no argument found */ 143 | 144 | while (buf_consume(buf, ' ')) {} 145 | 146 | if (negated || !p->has_argument) 147 | errx(1, "%s option %.*s does not take an argument", msg, 148 | (int)option_len, untrusted_option); 149 | 150 | if (buf->untrusted_cursor[0] && buf->untrusted_cursor[0] != ',') 151 | consume_subpackets_argument(buf); 152 | 153 | if (buf->untrusted_cursor[0] && !is_delim(buf->untrusted_cursor[0])) 154 | errx(1, "Only a space or comma can follow a %s option argument " 155 | "(found %c)", msg, buf->untrusted_cursor[0]); 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/gpg-server.c: -------------------------------------------------------------------------------- 1 | #include 2 | #include 3 | #include 4 | #include 5 | #include 6 | #include 7 | #include 8 | #include 9 | 10 | #include "gpg-common.h" 11 | #include "multiplex.h" 12 | 13 | const bool is_client = false; 14 | 15 | int main(int argc, char *argv[]) 16 | { 17 | struct command_hdr untrusted_hdr; 18 | int len; 19 | int i; 20 | int remote_argc, parsed_argc; 21 | // use static both to reduce stack space and ensure NULL-termination 22 | // client can pass up to COMMAND_MAX_LEN-1 arguments, but argument 0 23 | // is another argument and there is also the NULL pointer that 24 | // terminates the argv array 25 | static char *(untrusted_remote_argv[COMMAND_MAX_LEN+1]); 26 | // same as above, but add 1 for each argument added by the server 27 | static char *(remote_argv[COMMAND_MAX_LEN+6]); 28 | int input_fds[MAX_FDS], output_fds[MAX_FDS]; 29 | int input_fds_count, output_fds_count; 30 | 31 | if (argc < 3) { 32 | fprintf(stderr, "ERROR: Too few arguments\n"); 33 | fprintf(stderr, "Usage: %s \n", 34 | argv[0]); 35 | exit(1); 36 | } 37 | 38 | len = read(0, &untrusted_hdr, sizeof(untrusted_hdr)); 39 | if (len < 0) { 40 | perror("read header"); 41 | exit(1); 42 | } else if (len != sizeof(untrusted_hdr)) { 43 | fprintf(stderr, "ERROR: Invalid header size: %d\n", len); 44 | exit(1); 45 | } 46 | if (untrusted_hdr.len >= COMMAND_MAX_LEN) { 47 | fprintf(stderr, "ERROR: Command too long\n"); 48 | exit(1); 49 | } 50 | len = untrusted_hdr.len; 51 | // Check that the sender NUL-terminated their command 52 | if (untrusted_hdr.command[len]) 53 | errx(1, "ERROR: command not NUL-terminated"); 54 | // split command line into argv 55 | remote_argc = 0; 56 | untrusted_remote_argv[remote_argc++] = argv[1]; 57 | for (i = 0; i < len; i++) { 58 | if (untrusted_hdr.command[i] == 0) { 59 | untrusted_remote_argv[remote_argc++] = &untrusted_hdr.command[i + 1]; 60 | } 61 | } 62 | 63 | // parse arguments and do not allow any non-option argument 64 | if ((parsed_argc=parse_options 65 | (remote_argc, untrusted_remote_argv, input_fds, &input_fds_count, 66 | output_fds, &output_fds_count)) < remote_argc) { 67 | /* allow single "-" argument */ 68 | if (parsed_argc+1 < remote_argc || 69 | strcmp(untrusted_remote_argv[parsed_argc], "-") != 0) { 70 | fprintf(stderr, 71 | "ERROR: Non-option arguments not allowed\n"); 72 | exit(1); 73 | } 74 | } 75 | 76 | memcpy(remote_argv + 6, untrusted_remote_argv + 1, 77 | sizeof(untrusted_remote_argv) - sizeof(untrusted_remote_argv[0])); 78 | /* now options are verified and we get here only when all are allowed */ 79 | remote_argv[0] = argv[1]; 80 | // provide a better error message than "inappropriate ioctl for device" 81 | remote_argv[1] = "--no-tty"; 82 | // disable use of dirmngr, which makes no sense in a backend qube 83 | remote_argv[2] = "--disable-dirmngr"; 84 | // prevent a photo viewer from being launched 85 | remote_argv[3] = "--photo-viewer=/bin/true"; 86 | // force batch mode 87 | remote_argv[4] = "--batch"; 88 | // ensure exit on status write error 89 | remote_argv[5] = "--exit-on-status-write-error"; 90 | // Already NULL terminated as arrays are static, thus 0-initialized 91 | 92 | return prepare_pipes_and_run(argv[1], remote_argv, input_fds, 93 | input_fds_count, output_fds, 94 | output_fds_count); 95 | } 96 | -------------------------------------------------------------------------------- /src/multiplex.c: -------------------------------------------------------------------------------- 1 | /* 2 | * The Qubes OS Project, http://www.qubes-os.org 3 | * 4 | * Copyright (C) 2011 Marek Marczykowski 5 | * 6 | * This program is free software; you can redistribute it and/or 7 | * modify it under the terms of the GNU General Public License 8 | * as published by the Free Software Foundation; either version 2 9 | * of the License, or (at your option) any later version. 10 | * 11 | * This program is distributed in the hope that it will be useful, 12 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | * GNU General Public License for more details. 15 | * 16 | * You should have received a copy of the GNU General Public License 17 | * along with this program; if not, write to the Free Software 18 | * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 19 | * 20 | */ 21 | 22 | #define _GNU_SOURCE 23 | #include 24 | #include 25 | #include 26 | #include 27 | #include 28 | #include 29 | #include 30 | #include 31 | 32 | 33 | #include 34 | #include "multiplex.h" 35 | #include "gpg-common.h" 36 | 37 | #define BUF_SIZE 4096 38 | 39 | static volatile int child_status = -1; 40 | 41 | struct thread_args{ 42 | int multi_fd; 43 | int *fds; 44 | int fds_count; 45 | }; 46 | 47 | static void sigchld_handler(int arg __attribute__((__unused__))) 48 | { 49 | int stat_loc; 50 | wait(&stat_loc); 51 | if (WIFEXITED(stat_loc)) 52 | child_status = WEXITSTATUS(stat_loc); 53 | else if (WIFSIGNALED(stat_loc)) 54 | child_status = 128 + WTERMSIG(stat_loc); 55 | } 56 | 57 | static void sigpipe_handler(int arg __attribute__((__unused__))) {} 58 | 59 | void setup_sigchld(void) 60 | { 61 | struct sigaction sa; 62 | memset(&sa, 0, sizeof sa); 63 | sa.sa_handler = sigchld_handler; 64 | sa.sa_flags = 0; 65 | sigemptyset(&sa.sa_mask); 66 | if (sigaction(SIGCHLD, &sa, NULL) != 0) { 67 | perror("sigaction"); 68 | exit(1); 69 | } 70 | 71 | sa.sa_handler = sigpipe_handler; 72 | if (sigaction(SIGPIPE, &sa, NULL) != 0) { 73 | perror("sigaction"); 74 | exit(1); 75 | } 76 | } 77 | 78 | _Noreturn static void *process_in(struct thread_args *args) { 79 | int fd_input = args->multi_fd; 80 | int *write_fds = args->fds; 81 | int write_fds_len = args->fds_count; 82 | char buf[BUF_SIZE]; 83 | struct header hdr, untrusted_hdr; 84 | int read_len, write_len; 85 | unsigned total_read_len; 86 | unsigned total_write_len; 87 | 88 | while (1) { 89 | total_read_len=0; 90 | while (total_read_len < sizeof(untrusted_hdr)) { 91 | read_len = read(fd_input, (&untrusted_hdr)+total_read_len, 92 | sizeof(struct header)-total_read_len); 93 | switch (read_len) { 94 | case 0: 95 | fprintf(stderr, "EOF\n"); 96 | exit(is_client ? EXIT_FAILURE : EXIT_SUCCESS); 97 | case -1: 98 | perror("read(hdr)"); 99 | exit(EXIT_FAILURE); 100 | } 101 | total_read_len += read_len; 102 | } 103 | /* header sanitization begin */ 104 | if (untrusted_hdr.len > BUF_SIZE) { 105 | fprintf(stderr, 106 | "ERROR: Invalid block size received (%d)", untrusted_hdr.len); 107 | exit(EXIT_FAILURE); 108 | } 109 | if (untrusted_hdr.fd_num >= write_fds_len) { 110 | fprintf(stderr, 111 | "ERROR: invalid fd number"); 112 | exit(EXIT_FAILURE); 113 | } 114 | hdr = untrusted_hdr; 115 | /* header sanitization end */ 116 | if (hdr.fd_num < 0) { 117 | // received exit status from another side 118 | exit(-(hdr.fd_num + 1)); 119 | } 120 | if (hdr.len == 0) { 121 | // EOF received at the other side 122 | close(write_fds[hdr.fd_num]); 123 | write_fds[hdr.fd_num] = -1; 124 | } else { 125 | /* data block can be sent in more than one chunk via vchan 126 | * (because of vchan buffer size) */ 127 | total_read_len = 0; 128 | while (total_read_len < hdr.len) { 129 | read_len = read(fd_input, 130 | buf+total_read_len, 131 | hdr.len-total_read_len); 132 | if (read_len < 0) { 133 | perror("read"); 134 | exit(EXIT_FAILURE); 135 | } else if (read_len == 0) { 136 | fprintf(stderr, 137 | "ERROR: received incomplete block " 138 | "(expected %d, got %d)", hdr.len, total_read_len); 139 | exit(EXIT_FAILURE); 140 | } 141 | total_read_len += read_len; 142 | } 143 | /* we are not validating data passed to/from gpg */ 144 | total_write_len=0; 145 | while (total_write_len < total_read_len) { 146 | write_len = write(write_fds[hdr.fd_num], 147 | buf+total_write_len, 148 | total_read_len-total_write_len); 149 | if (write_len == -1) { 150 | switch (errno) { 151 | case EPIPE: 152 | close(write_fds[hdr.fd_num]); 153 | write_fds[hdr.fd_num] = -1; 154 | __attribute__((fallthrough)); 155 | case EBADF: 156 | /* broken pipes are not fatal, 157 | * just discard all data */ 158 | total_write_len = total_read_len - write_len; 159 | break; 160 | default: 161 | perror("write"); 162 | exit(EXIT_FAILURE); 163 | } 164 | } 165 | total_write_len += write_len; 166 | } 167 | } 168 | } 169 | } 170 | 171 | static _Noreturn void *process_out(struct thread_args *args) { 172 | int fd_output = args->multi_fd; 173 | int *read_fds = args->fds; 174 | int read_fds_len = args->fds_count; 175 | char buf[BUF_SIZE]; 176 | struct pollfd fds[MAX_FDS]; 177 | int closed_fds_count = 0; 178 | int i, read_len; 179 | struct header hdr; 180 | sigset_t empty_set; 181 | 182 | memset(fds, 0, sizeof(fds)); 183 | assert(read_fds_len < MAX_FDS); 184 | 185 | sigemptyset(&empty_set); 186 | 187 | for (i = 0; i < read_fds_len; i++) 188 | fds[i] = (struct pollfd) { .fd = read_fds[i], .events = POLLIN | POLLHUP, .revents = 0 }; 189 | 190 | while (1) { 191 | if (ppoll(fds, read_fds_len, NULL, &empty_set) < 0) { 192 | if (errno != EINTR) { 193 | perror("ppoll"); 194 | exit(EXIT_FAILURE); 195 | } else { 196 | //EINTR 197 | if (closed_fds_count == read_fds_len && !is_client) { 198 | //if child status saved - send it to the other side 199 | if (child_status >= 0) { 200 | hdr.fd_num = -(child_status + 1); 201 | hdr.len = 0; 202 | if (write(fd_output, &hdr, sizeof(hdr)) < 0) { 203 | perror("write"); 204 | exit(EXIT_FAILURE); 205 | } 206 | } 207 | exit(EXIT_SUCCESS); 208 | } else { 209 | // read remaining data and then exit 210 | continue; 211 | } 212 | } 213 | } 214 | for (i = 0; i < read_fds_len; i++) { 215 | assert(i >= 0 && i < MAX_FDS); 216 | if (fds[i].revents) { 217 | // just one block 218 | read_len = read(read_fds[i], buf, BUF_SIZE); 219 | /* we are not validating data passed to/from gpg */ 220 | if (read_len < 0) { 221 | perror("read"); 222 | exit(EXIT_FAILURE); 223 | } 224 | hdr.fd_num = i; 225 | hdr.len = read_len; 226 | // can block, but not a problem 227 | if (write(fd_output, &hdr, sizeof(hdr)) < 0) { 228 | perror("write"); 229 | exit(EXIT_FAILURE); 230 | } 231 | if (read_len == 0) { 232 | // closed pipe 233 | close(fds[i].fd); 234 | fds[i].fd = -1; 235 | closed_fds_count++; 236 | // if it was the last one - send child exit status 237 | if (closed_fds_count == read_fds_len && child_status >= 0 && !is_client) 238 | { 239 | hdr.fd_num = -(child_status + 1); 240 | hdr.len = 0; 241 | if (write (fd_output, &hdr, sizeof(hdr)) < 0) { 242 | perror("write"); 243 | exit(EXIT_FAILURE); 244 | } 245 | exit(EXIT_SUCCESS); 246 | } 247 | } else { 248 | // can block, but not a problem 249 | if (write(fd_output, buf, read_len) < 0) { 250 | perror("write"); 251 | exit(EXIT_FAILURE); 252 | } 253 | } 254 | } 255 | } 256 | } 257 | } 258 | 259 | _Noreturn int process_io(int fd_input, int fd_output, int *read_fds, 260 | int read_fds_len, int *write_fds, int write_fds_len) 261 | { 262 | pthread_t thread_in; 263 | struct thread_args thread_in_args, thread_out_args; 264 | sigset_t chld_set; 265 | int i; 266 | 267 | thread_in_args.multi_fd = fd_input; 268 | thread_in_args.fds = write_fds; 269 | thread_in_args.fds_count = write_fds_len; 270 | 271 | thread_out_args.multi_fd = fd_output; 272 | thread_out_args.fds = read_fds; 273 | thread_out_args.fds_count = read_fds_len; 274 | 275 | sigemptyset(&chld_set); 276 | sigaddset(&chld_set, SIGCHLD); 277 | if ((i = pthread_sigmask(SIG_BLOCK, &chld_set, NULL))) { 278 | errno = i; 279 | perror("pthread_sigmask"); 280 | exit(EXIT_FAILURE); 281 | } 282 | if (pthread_create(&thread_in, NULL, (void * (*)(void *))process_in, (void*)&thread_in_args) != 0) { 283 | perror("pthread_create(thread_in)"); 284 | exit(EXIT_FAILURE); 285 | } 286 | process_out(&thread_out_args); 287 | } 288 | -------------------------------------------------------------------------------- /src/multiplex.h: -------------------------------------------------------------------------------- 1 | #ifndef _MULTIPLEX_H 2 | #define _MULTIPLEX_H 3 | 4 | /* 5 | * The Qubes OS Project, http://www.qubes-os.org 6 | * 7 | * Copyright (C) 2011 Marek Marczykowski 8 | * 9 | * This program is free software; you can redistribute it and/or 10 | * modify it under the terms of the GNU General Public License 11 | * as published by the Free Software Foundation; either version 2 12 | * of the License, or (at your option) any later version. 13 | * 14 | * This program is distributed in the hope that it will be useful, 15 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | * GNU General Public License for more details. 18 | * 19 | * You should have received a copy of the GNU General Public License 20 | * along with this program; if not, write to the Free Software 21 | * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. 22 | * 23 | */ 24 | 25 | #include 26 | 27 | struct header { 28 | int fd_num; 29 | unsigned int len; 30 | }; 31 | 32 | int process_io(int fd_input, int fd_output, int *read_fds, 33 | int read_fds_len, int *write_fds, int write_fds_len); 34 | 35 | void setup_sigchld(void); 36 | 37 | 38 | #endif /* _MULTIPLEX_H */ 39 | -------------------------------------------------------------------------------- /tests/Makefile: -------------------------------------------------------------------------------- 1 | # 2 | # The Qubes OS Project, http://www.qubes-os.org 3 | # 4 | # Copyright (C) 2016 Marek Marczykowski-Górecki 5 | # 6 | # 7 | # This program is free software; you can redistribute it and/or 8 | # modify it under the terms of the GNU General Public License 9 | # as published by the Free Software Foundation; either version 2 10 | # of the License, or (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program; if not, write to the Free Software 19 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, 20 | # USA. 21 | # 22 | 23 | PYTHON2 ?= python 24 | PYTHON3 ?= python3 25 | 26 | all: 27 | @true 28 | 29 | install-dom0: install-dom0-py2 install-dom0-py3 30 | 31 | install-dom0-py2: 32 | $(PYTHON2) setup.py install -O1 --root $(DESTDIR) 33 | 34 | install-dom0-py3: 35 | $(PYTHON3) setup.py install -O1 --root $(DESTDIR) 36 | 37 | install-vm: 38 | install -d $(DESTDIR)/usr/lib/qubes-gpg-split 39 | install test_*.py $(DESTDIR)/usr/lib/qubes-gpg-split/ 40 | 41 | install-vm-deb: install-whonix-systemd-dropins 42 | 43 | install-whonix-systemd-dropins: 44 | install -d $(DESTDIR)/lib/systemd/system/bootclockrandomization.service.d/ 45 | install whonix-clock-override.conf $(DESTDIR)/lib/systemd/system/bootclockrandomization.service.d/override.conf -------------------------------------------------------------------------------- /tests/setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | # 4 | # The Qubes OS Project, http://www.qubes-os.org 5 | # 6 | # Copyright (C) 2016 Marek Marczykowski-Górecki 7 | # 8 | # 9 | # This program is free software; you can redistribute it and/or 10 | # modify it under the terms of the GNU General Public License 11 | # as published by the Free Software Foundation; either version 2 12 | # of the License, or (at your option) any later version. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program; if not, write to the Free Software 21 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, 22 | # USA. 23 | # 24 | 25 | from setuptools import setup 26 | 27 | setup( 28 | name='splitgpg', 29 | version=open('../version').read().strip(), 30 | packages=['splitgpg'], 31 | entry_points={ 32 | 'qubes.tests.extra.for_template': 33 | 'splitgpg = splitgpg.tests:list_tests', 34 | } 35 | ) 36 | -------------------------------------------------------------------------------- /tests/splitgpg/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/QubesOS/qubes-app-linux-split-gpg/5261762f3d116d19fb979c0b6a14b98bf9c37aa7/tests/splitgpg/__init__.py -------------------------------------------------------------------------------- /tests/splitgpg/tests.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # The Qubes OS Project, http://www.qubes-os.org 4 | # 5 | # Copyright (C) 2016 Marek Marczykowski-Górecki 6 | # 7 | # 8 | # This program is free software; you can redistribute it and/or 9 | # modify it under the terms of the GNU General Public License 10 | # as published by the Free Software Foundation; either version 2 11 | # of the License, or (at your option) any later version. 12 | # 13 | # This program is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with this program; if not, write to the Free Software 20 | # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, 21 | # USA. 22 | # 23 | import unittest 24 | 25 | import qubes.tests.extra 26 | 27 | 28 | class SplitGPGBase(qubes.tests.extra.ExtraTestCase): 29 | def setUp(self): 30 | super(SplitGPGBase, self).setUp() 31 | self.enable_network() 32 | self.backend, self.frontend = self.create_vms(["backend", "frontend"]) 33 | 34 | self.backend.start() 35 | if self.backend.run('ls /etc/qubes-rpc/qubes.Gpg', wait=True) != 0: 36 | self.skipTest('gpg-split not installed') 37 | # Whonix desynchronize time on purpose, so make sure the key is 38 | # generated in the past even when the frontend have clock few minutes 39 | # into the future - otherwise new key may look as 40 | # generated in the future and be considered not yet valid 41 | if 'whonix' in self.template: 42 | self.backend.run("date -s -10min", user="root", wait=True) 43 | p = self.backend.run('mkdir -p -m 0700 .gnupg; gpg2 --gen-key --batch', 44 | passio_popen=True, 45 | passio_stderr=True) 46 | p.communicate(''' 47 | Key-Type: RSA 48 | Key-Length: 1024 49 | Key-Usage: sign 50 | Subkey-Type: RSA 51 | Subkey-Length: 1024 52 | Subkey-Usage: encrypt 53 | Name-Real: Qubes test 54 | Name-Email: user@localhost 55 | Expire-Date: 0 56 | %no-protection 57 | %commit 58 | '''.encode()) 59 | if p.returncode == 127: 60 | self.skipTest('gpg2 not installed') 61 | elif p.returncode != 0: 62 | self.fail('key generation failed') 63 | if 'whonix' in self.template: 64 | self.backend.run("date -s +10min", user="root", wait=True) 65 | 66 | self.fake_confirmation() 67 | 68 | self.frontend.start() 69 | p = self.frontend.run('tee /rw/config/gpg-split-domain', 70 | passio_popen=True, user='root') 71 | p.communicate(self.backend.name.encode()) 72 | 73 | self.qrexec_policy('qubes.Gpg', self.frontend.name, self.backend.name) 74 | self.qrexec_policy('qubes.GpgImportKey', self.frontend.name, 75 | self.backend.name) 76 | 77 | def fake_confirmation(self): 78 | # fake confirmation 79 | self.backend.run( 80 | 'touch /var/run/qubes-gpg-split/stat.{}'.format( 81 | self.frontend.name), wait=True) 82 | 83 | 84 | class TC_00_Direct(SplitGPGBase): 85 | def test_000_version(self): 86 | cmd = 'qubes-gpg-client-wrapper --version' 87 | p = self.frontend.run(cmd, wait=True) 88 | self.assertEqual(p, 0, '{} failed'.format(cmd)) 89 | 90 | def test_010_list_keys(self): 91 | cmd = 'qubes-gpg-client-wrapper --list-keys' 92 | p = self.frontend.run(cmd, passio_popen=True, passio_stderr=True) 93 | (keys, stderr) = p.communicate() 94 | self.assertEqual(p.returncode, 0, 95 | '{} failed: {}'.format(cmd, stderr.decode())) 96 | self.assertIn("Qubes test", keys.decode()) 97 | 98 | def test_020_export_secret_key_deny(self): 99 | # TODO check if backend really deny such operation, here it is denied 100 | # by the frontend 101 | cmd = 'qubes-gpg-client-wrapper -a --export-secret-keys user@localhost' 102 | p = self.frontend.run(cmd, passio_popen=True, passio_stderr=True) 103 | keys, stderr = p.communicate() 104 | self.assertNotEqual(p.returncode, 0, 105 | '{} succeeded unexpectedly: {}'.format(cmd, stderr.decode())) 106 | self.assertEqual(keys.decode(), '') 107 | 108 | def test_030_sign_verify(self): 109 | msg = "Test message" 110 | cmd = 'qubes-gpg-client-wrapper -a --sign' 111 | p = self.frontend.run(cmd, passio_popen=True, passio_stderr=True) 112 | (signature, stderr) = p.communicate(msg.encode()) 113 | self.assertEqual(p.returncode, 0, 114 | '{} failed: {}'.format(cmd, stderr.decode())) 115 | self.assertNotEqual('', signature.decode()) 116 | 117 | # verify first through gpg-split 118 | cmd = 'qubes-gpg-client-wrapper' 119 | p = self.frontend.run(cmd, passio_popen=True, passio_stderr=True) 120 | (decoded_msg, verification_result) = p.communicate(signature) 121 | self.assertEqual(p.returncode, 0, 122 | '{} failed: {}'.format(cmd, verification_result.decode())) 123 | self.assertEqual(decoded_msg.decode(), msg) 124 | self.assertIn('\ngpg: Good signature from', verification_result.decode()) 125 | 126 | # verify in frontend directly 127 | cmd = 'gpg2 -a --export user@localhost' 128 | p = self.backend.run(cmd, passio_popen=True, passio_stderr=True) 129 | (pubkey, stderr) = p.communicate() 130 | self.assertEqual(p.returncode, 0, 131 | '{} failed: {}'.format(cmd, stderr.decode())) 132 | cmd = 'gpg2 --import' 133 | p = self.frontend.run(cmd, passio_popen=True, passio_stderr=True) 134 | (stdout, stderr) = p.communicate(pubkey) 135 | self.assertEqual(p.returncode, 0, 136 | '{} failed: {}{}'.format(cmd, stdout.decode(), stderr.decode())) 137 | cmd = "gpg2" 138 | p = self.frontend.run(cmd, passio_popen=True, passio_stderr=True) 139 | decoded_msg, verification_result = p.communicate(signature) 140 | self.assertEqual(p.returncode, 0, 141 | '{} failed: {}'.format(cmd, verification_result.decode())) 142 | self.assertEqual(decoded_msg.decode(), msg) 143 | self.assertIn('\ngpg: Good signature from', verification_result.decode()) 144 | 145 | def test_031_sign_verify_detached(self): 146 | msg = "Test message" 147 | self.frontend.run('echo "{}" > message'.format(msg), wait=True) 148 | cmd = 'qubes-gpg-client-wrapper -a -b --sign message > signature.asc' 149 | p = self.frontend.run(cmd, passio_popen=True, passio_stderr=True) 150 | stdout, stderr = p.communicate() 151 | self.assertEqual(p.returncode, 0, 152 | '{} failed: {}'.format(cmd, stderr.decode())) 153 | 154 | # verify through gpg-split 155 | cmd = 'qubes-gpg-client-wrapper --verify signature.asc message' 156 | p = self.frontend.run(cmd, passio_popen=True, passio_stderr=True) 157 | decoded_msg, verification_result = p.communicate() 158 | self.assertEqual(p.returncode, 0, 159 | '{} failed: {}'.format(cmd, verification_result.decode())) 160 | self.assertEqual(decoded_msg.decode(), '') 161 | self.assertIn('\ngpg: Good signature from', verification_result.decode()) 162 | 163 | # break the message and check again 164 | self.frontend.run('echo "{}" >> message'.format(msg), wait=True) 165 | cmd = 'qubes-gpg-client-wrapper --verify signature.asc message' 166 | p = self.frontend.run(cmd, passio_popen=True, passio_stderr=True) 167 | decoded_msg, verification_result = p.communicate() 168 | self.assertNotEqual(p.returncode, 0, 169 | '{} unexpecedly succeeded: {}'.format(cmd, verification_result.decode())) 170 | self.assertEqual(decoded_msg.decode(), '') 171 | self.assertIn('\ngpg: BAD signature from', verification_result.decode()) 172 | 173 | def test_040_import(self): 174 | # see comment in setUp() 175 | if 'whonix' in self.template: 176 | self.frontend.run("date -s -10min", user="root", wait=True) 177 | p = self.frontend.run('mkdir -p -m 0700 .gnupg; gpg2 --gen-key --batch', 178 | passio_popen=True) 179 | p.communicate(''' 180 | Key-Type: RSA 181 | Key-Length: 1024 182 | Key-Usage: sign 183 | Subkey-Type: RSA 184 | Subkey-Length: 1024 185 | Subkey-Usage: encrypt 186 | Name-Real: Qubes test2 187 | Name-Email: user2@localhost 188 | Expire-Date: 0 189 | %no-protection 190 | %commit 191 | '''.encode()) 192 | assert p.returncode == 0, 'key generation failed' 193 | # see comment in setUp() 194 | if 'whonix' in self.template: 195 | self.frontend.run("date -s +10min", user="root", wait=True) 196 | 197 | p = self.frontend.run('qubes-gpg-client-wrapper --list-keys', 198 | passio_popen=True) 199 | (key_list, _) = p.communicate() 200 | self.assertNotIn('user2@localhost', key_list.decode()) 201 | p = self.frontend.run('gpg2 -a --export user2@localhost | ' 202 | 'QUBES_GPG_DOMAIN={} qubes-gpg-import-key'.format(self.backend.name), 203 | passio_popen=True, passio_stderr=True) 204 | (stdout, stderr) = p.communicate() 205 | self.assertEqual(p.returncode, 0, "Failed to import key: " + 206 | stderr.decode()) 207 | p = self.frontend.run('qubes-gpg-client-wrapper --list-keys', 208 | passio_popen=True) 209 | (key_list, _) = p.communicate() 210 | self.assertIn('user2@localhost', key_list.decode()) 211 | 212 | def test_041_import_via_wrapper(self): 213 | # see comment in setUp() 214 | if 'whonix' in self.template: 215 | self.frontend.run("date -s -10min", user="root", wait=True) 216 | p = self.frontend.run('mkdir -p -m 0700 .gnupg; gpg2 --gen-key --batch', 217 | passio_popen=True, passio_stderr=True) 218 | stdout, stderr = p.communicate(''' 219 | Key-Type: RSA 220 | Key-Length: 1024 221 | Key-Usage: sign 222 | Subkey-Type: RSA 223 | Subkey-Length: 1024 224 | Subkey-Usage: encrypt 225 | Name-Real: Qubes test2 226 | Name-Email: user2@localhost 227 | Expire-Date: 0 228 | %no-protection 229 | %commit 230 | '''.encode()) 231 | assert p.returncode == 0, 'key generation failed: {}'.format( 232 | stderr.decode()) 233 | # see comment in setUp() 234 | if 'whonix' in self.template: 235 | self.frontend.run("date -s +10min", user="root", wait=True) 236 | 237 | p = self.frontend.run('qubes-gpg-client-wrapper --list-keys', 238 | passio_popen=True) 239 | (key_list, _) = p.communicate() 240 | self.assertNotIn('user2@localhost', key_list.decode()) 241 | p = self.frontend.run('gpg2 -a --export user2@localhost | ' 242 | 'QUBES_GPG_DOMAIN={} qubes-gpg-client-wrapper --import'.format( 243 | self.backend.name), 244 | passio_popen=True, passio_stderr=True) 245 | (stdout, stderr) = p.communicate() 246 | self.assertEqual(p.returncode, 0, "Failed to import key: " + 247 | stderr.decode()) 248 | p = self.frontend.run('qubes-gpg-client-wrapper --list-keys', 249 | passio_popen=True) 250 | (key_list, _) = p.communicate() 251 | self.assertIn('user2@localhost', key_list.decode()) 252 | 253 | 254 | def test_050_sign_verify_files(self): 255 | """Test for --output option""" 256 | msg = "Test message" 257 | cmd = 'qubes-gpg-client-wrapper -a --sign --output /tmp/signed.asc' 258 | p = self.frontend.run(cmd, passio_popen=True, passio_stderr=True) 259 | stdout, stderr = p.communicate(msg.encode()) 260 | self.assertEqual(p.returncode, 0, 261 | '{} failed: {}'.format(cmd, stderr.decode())) 262 | 263 | # verify first through gpg-split 264 | cmd = 'qubes-gpg-client-wrapper /tmp/signed.asc' 265 | p = self.frontend.run(cmd, passio_popen=True, passio_stderr=True) 266 | decoded_msg, verification_result = p.communicate() 267 | self.assertEqual(p.returncode, 0, 268 | '{} failed: {}'.format(cmd, verification_result.decode())) 269 | self.assertEqual(decoded_msg.decode(), msg) 270 | self.assertIn('\ngpg: Good signature from', verification_result.decode()) 271 | 272 | def test_060_output_and_status_fd(self): 273 | """Regression test for #2057""" 274 | msg = "Test message" 275 | cmd = 'qubes-gpg-client-wrapper -a --sign --status-fd 1 --output ' \ 276 | '/tmp/signed.asc' 277 | p = self.frontend.run(cmd, passio_popen=True, passio_stderr=True) 278 | (stdout, stderr) = p.communicate(msg.encode()) 279 | self.assertEqual(p.returncode, 0, 280 | '{} failed: {}'.format(cmd, stderr.decode())) 281 | self.assertTrue(all(x.startswith('[GNUPG:]') for x in 282 | stdout.decode().splitlines()), "Non-status output on stdout") 283 | 284 | # verify first through gpg-split 285 | cmd = 'qubes-gpg-client-wrapper /tmp/signed.asc' 286 | p = self.frontend.run(cmd, passio_popen=True, passio_stderr=True) 287 | decoded_msg, verification_result = p.communicate() 288 | self.assertEqual(p.returncode, 0, 289 | '{} failed: {}'.format(cmd, verification_result.decode())) 290 | self.assertEqual(decoded_msg.decode(), msg) 291 | self.assertIn('\ngpg: Good signature from', verification_result.decode()) 292 | 293 | def test_070_log_file_to_logger_fd(self): 294 | """Regression test for #3989""" 295 | msg = "Test message" 296 | cmd = 'qubes-gpg-client-wrapper -a --sign --log-file /tmp/gpg.log ' \ 297 | '--verbose --output /tmp/signed.asc' 298 | p = self.frontend.run(cmd, passio_popen=True, passio_stderr=True) 299 | (stdout, stderr) = p.communicate(msg.encode()) 300 | self.assertEqual(p.returncode, 0, '{} failed: {}'.format(cmd, 301 | stderr.decode())) 302 | self.assertTrue(all(x.startswith('[GNUPG:]') for x in 303 | stdout.decode().splitlines()), "Non-status output on stdout") 304 | p = self.frontend.run('cat /tmp/gpg.log', 305 | passio_popen=True, passio_stderr=True) 306 | (stdout, stderr) = p.communicate() 307 | self.assertIn('signature from', stdout.decode()) 308 | 309 | # verify first through gpg-split 310 | cmd = 'qubes-gpg-client-wrapper /tmp/signed.asc' 311 | p = self.frontend.run(cmd, passio_popen=True, passio_stderr=True) 312 | decoded_msg, verification_result = p.communicate() 313 | self.assertEqual(p.returncode, 0, 314 | '{} failed: {}'.format(cmd, verification_result.decode())) 315 | self.assertEqual(decoded_msg.decode(), msg) 316 | self.assertIn('\ngpg: Good signature from', verification_result.decode()) 317 | 318 | def _check_if_options_takes_argument(self, prog, option, message_fmts): 319 | """Check whether an option expect an argument or not. 320 | The *prog* will be called with *option* and --garbage-1 --garbage-2. 321 | Based on which one is rejected, it will deduce whether the option 322 | requires an argument or not. 323 | 324 | :param prog: program to test (gpg2, qubes-gpg-client) 325 | :param option: option to test 326 | :param message_fmt: error message format, about rejected option 327 | """ 328 | 329 | # check if option requires an argument by seeing if --garbage-1 was 330 | # interpreted as another option, or an argument 331 | cmd = '{} {} --garbage-1 --garbage-2'.format(prog, option) 332 | p = self.frontend.run(cmd, passio_popen=True, passio_stderr=True) 333 | (stdout, stderr) = p.communicate() 334 | stderr = stderr.decode() 335 | self.assertNotEqual(p.returncode, 0, 336 | cmd + ' should have failed: ' + stderr) 337 | if option == '--list-options' and 'qubes' in prog: 338 | self.assertEqual(stderr, 339 | "qubes-gpg-client: Unknown list option --garbage-1\n") 340 | return True 341 | if option == '--verify-options' and 'qubes' in prog: 342 | self.assertEqual(stderr, 343 | "qubes-gpg-client: Unknown verify option --garbage-1\n") 344 | return True 345 | if option == '--export-options' and 'qubes' in prog: 346 | self.assertEqual(stderr, 347 | "qubes-gpg-client: Unknown export option --garbage-1\n") 348 | return True 349 | for message_fmt in message_fmts: 350 | if message_fmt.format('--garbage-1') in stderr: 351 | return False 352 | if message_fmt.format('--garbage-2') in stderr: 353 | return True 354 | if message_fmt.format(option) in stderr: 355 | return None 356 | if 'invalid argument for option "{}"'.format(option) in stderr: 357 | return True 358 | if 'Invalid fd argument' in stderr: 359 | return True 360 | self.fail( 361 | '{} {} have not complained about --garbage options: {}'.format( 362 | 'qubes-gpg-client' if 'qubes' in prog else 'gpg2', 363 | option, stderr)) 364 | 365 | def test_080_option_parser(self): 366 | """Check if split-gpg agrees with gpg about options parsing""" 367 | cmd = 'gpg --dump-options' 368 | p = self.frontend.run(cmd, passio_popen=True, passio_stderr=True) 369 | (stdout, stderr) = p.communicate() 370 | self.assertEqual(p.returncode, 0, '{} failed: {}'.format(cmd, 371 | stderr.decode())) 372 | all_options = stdout.decode().splitlines() 373 | noarg_options = [] 374 | for opt in all_options: 375 | if opt in ('--output', '--logger-fd', '--version'): 376 | # those options are problematic for testing (different error 377 | # messages, messes with logging) and are checked manually 378 | continue 379 | splitgpg_needs_arg = self._check_if_options_takes_argument( 380 | 'QUBES_GPG_DOMAIN={} qubes-gpg-client'.format(self.backend.name), 381 | opt, ['unrecognized option \'{}\'', 382 | 'option \'{}\' is ambiguous', 383 | 'Forbidden option: {}']) 384 | if splitgpg_needs_arg is None: 385 | # option rejected 386 | continue 387 | gpg_needs_arg = self._check_if_options_takes_argument( 388 | 'gpg2', opt, ['invalid option "{}"']) 389 | self.assertEqual(gpg_needs_arg, splitgpg_needs_arg, 390 | 'gpg and splitgpg disagrees on {} option: {}, {}'.format( 391 | opt, gpg_needs_arg, splitgpg_needs_arg)) 392 | if not gpg_needs_arg: 393 | noarg_options.append(opt) 394 | # TODO: Test if gpg agrees with split-gpg, whether positional 395 | # argument(s) are a path or user id. Somehow... 396 | 397 | def test_081_subpacket_options(self): 398 | """Check if split-gpg agrees with gpg about subpacket options parsing""" 399 | p = self.frontend.run('QUBES_GPG_DOMAIN=bogus qubes-gpg-client ' 400 | '--list-options show-sig-subpackets=1+1', 401 | passio_popen=True, passio_stderr=True) 402 | (stdout, stderr) = p.communicate() 403 | self.assertEqual(stdout, b'', 'nothing should appear on stdout') 404 | self.assertEqual(stderr, 405 | b'qubes-gpg-client: Invalid character + following ' 406 | b'subpacket number\n', 407 | 'bad subpacket number not rejected properly') 408 | assert p.wait() == 1, 'wrong exit code' 409 | 410 | # TODO: 411 | # - encrypt/decrypt 412 | # - large file (bigger than pipe/qrexec buffers) 413 | 414 | 415 | class TC_10_Thunderbird(SplitGPGBase): 416 | 417 | scriptpath = '/usr/lib/qubes-gpg-split/test_thunderbird.py' 418 | 419 | def setUp(self): 420 | if self.template.startswith('whonix-gw'): 421 | self.skipTest('whonix-gw template not supported by this test') 422 | super(TC_10_Thunderbird, self).setUp() 423 | self.frontend.run_service('qubes.WaitForSession', wait=True, 424 | input='user') 425 | if self.frontend.run('which thunderbird', wait=True) == 0: 426 | self.tb_name = 'thunderbird' 427 | elif self.frontend.run('which icedove', wait=True) == 0: 428 | self.tb_name = 'icedove' 429 | else: 430 | self.skipTest('Thunderbird not installed') 431 | 432 | p = self.frontend.run('gsettings set org.gnome.desktop.interface ' 433 | 'toolkit-accessibility true', wait=True) 434 | assert p == 0, 'Failed to enable accessibility toolkit' 435 | if self.frontend.run( 436 | 'ls {}'.format(self.scriptpath), wait=True): 437 | self.skipTest('qubes-gpg-split-tests package not installed') 438 | 439 | # run as root to not deal with /var/mail permission issues 440 | self.frontend.run( 441 | 'mkdir -p Mail/new Mail/cur Mail/tmp', 442 | wait=True) 443 | 444 | # SMTP configuration 445 | self.smtp_server = self.frontend.run( 446 | 'aiosmtpd -n -c aiosmtpd.handlers.Mailbox /home/user/Mail', 447 | passio_popen=True) 448 | 449 | # IMAP configuration 450 | self.imap_pw = "pass" 451 | self.frontend.run( 452 | 'echo "mail_location=maildir:~/Mail" |\ 453 | sudo tee /etc/dovecot/conf.d/100-mail.conf', wait=True) 454 | self.frontend.run('sudo systemctl restart dovecot', wait=True) 455 | self.frontend.run( # set a user password because IMAP needs one for auth 456 | 'sudo usermod -p `echo "{}" | openssl passwd --stdin` user'\ 457 | .format(self.imap_pw), 458 | wait=True) 459 | 460 | self.setup_tb_profile(setup_openpgp=True) 461 | 462 | p = self.frontend.run( 463 | 'LC_ALL=C.UTF-8 ' 464 | 'python3 {} --tbname={} --profile {} --imap_pw {} setup 2>&1'.format( 465 | self.scriptpath, self.tb_name, self.profile_dir, self.imap_pw), 466 | passio_popen=True) 467 | (stdout, _) = p.communicate() 468 | assert p.returncode == 0, 'Thunderbird setup failed: {}'.format( 469 | stdout.decode('ascii', 'ignore')) 470 | 471 | # fake confirmation again, to give more time for the actual test 472 | self.fake_confirmation() 473 | 474 | def tearDown(self): 475 | self.smtp_server.terminate() 476 | del self.smtp_server 477 | super(TC_10_Thunderbird, self).tearDown() 478 | 479 | def get_key_fpr(self): 480 | cmd = '/usr/bin/qubes-gpg-client-wrapper -K --with-colons' 481 | p = self.frontend.run(cmd, passio_popen=True) 482 | (stdout, _) = p.communicate() 483 | self.assertEqual(p.returncode, 0, 'Failed to determin key id') 484 | keyid = stdout.decode('utf-8').split('\n')[1] 485 | keyid = keyid.split(':')[9] 486 | keyid = keyid[-16:] 487 | return keyid 488 | 489 | def setup_tb_profile(self, setup_openpgp): 490 | """SplitGPG Thunderbird Test Account Configuration 491 | 492 | Originally generated by running thunderbird for the first time 493 | and taking from ~/.thunderbird/.default/prefs.js all 494 | the relevant settings. Then adding the opengpg settings. 495 | """ 496 | 497 | profile_base = """ 498 | user_pref("mail.accountmanager.accounts", "account1"); 499 | user_pref("mail.accountmanager.defaultaccount", "account1"); 500 | user_pref("mail.account.account1.identities", "id1"); 501 | user_pref("mail.account.account1.server", "server1"); 502 | user_pref("mail.identity.id1.fullName", "user"); 503 | user_pref("mail.identity.id1.useremail", "user@localhost"); 504 | user_pref("mail.identity.id1.smtpServer", "smtp1"); 505 | user_pref("mail.identity.id1.compose_html", false); 506 | user_pref("datareporting.policy.dataSubmissionEnabled", false); // avoid message popups 507 | user_pref("app.donation.eoy.version.viewed", 100); // avoid message popups 508 | user_pref("mail.inappnotifications.enabled", false); // avoid message popups 509 | """ 510 | imap_server = """ 511 | user_pref("mail.server.server1.userName", "user"); 512 | user_pref("mail.server.server1.hostname", "localhost"); 513 | user_pref("mail.server.server1.login_at_startup", true); 514 | user_pref("mail.server.server1.name", "user@localhost"); 515 | user_pref("mail.server.server1.type", "imap"); 516 | user_pref("mail.server.server1.port", 143); 517 | """ 518 | smtp_server = """ 519 | user_pref("mail.smtpservers", "smtp1"); 520 | user_pref("mail.smtp.defaultserver", "smtp1"); 521 | user_pref("mail.smtpserver.smtp1.username", "user"); 522 | user_pref("mail.smtpserver.smtp1.hostname", "localhost"); 523 | user_pref("mail.smtpserver.smtp1.port", 8025); 524 | user_pref("mail.smtpserver.smtp1.authMethod", 3); // no auth 525 | user_pref("mail.smtpserver.smtp1.try_ssl", 0); // no encryption 526 | """ 527 | open_pgp = """ 528 | user_pref("mail.openpgp.allow_external_gnupg", true); 529 | user_pref("mail.openpgp.alternative_gpg_path", "/usr/bin/qubes-gpg-client-wrapper"); 530 | """ 531 | key_fingerprint = self.get_key_fpr() 532 | user_account_pgp = """ 533 | user_pref("mail.identity.id1.is_gnupg_key_id", true); 534 | user_pref("mail.identity.id1.last_entered_external_gnupg_key_id", "{}"); 535 | user_pref("mail.identity.id1.openpgp_key_id", "{}"); 536 | user_pref("mail.identity.id1.sign_mail", false); 537 | """.format(key_fingerprint, key_fingerprint) 538 | 539 | self.profile_dir = "$HOME/.thunderbird/qubes.default" 540 | user_js_path = self.profile_dir + "/user.js" 541 | 542 | user_js = profile_base + imap_server + smtp_server 543 | if setup_openpgp: 544 | user_js += open_pgp + user_account_pgp 545 | 546 | self.frontend.run('mkdir -p {}'.format(self.profile_dir), 547 | user='user', wait=True) 548 | p = self.frontend.run('cat > ' + user_js_path, 549 | user='user', passio_popen=True) 550 | (stdout, _) = p.communicate(user_js.encode()) 551 | assert p.returncode == 0, 'Thunderbird profile configuration failed: {}'\ 552 | .format(stdout.decode('ascii', 'ignore')) 553 | 554 | def test_000_send_receive_default(self): 555 | p = self.frontend.run( 556 | 'LC_ALL=C.UTF-8 ' 557 | 'python3 {} --tbname={} --profile {} --imap_pw {} send_receive ' 558 | '--encrypted --signed 2>&1'.format( 559 | self.scriptpath, self.tb_name, self.profile_dir, self.imap_pw), 560 | passio_popen=True) 561 | (stdout, _) = p.communicate() 562 | self.assertEqual(p.returncode, 0, 563 | 'Thunderbird send/receive failed: {}'.format( 564 | stdout.decode('ascii', 'ignore'))) 565 | 566 | def test_010_send_receive_inline_signed_only(self): 567 | p = self.frontend.run( 568 | 'LC_ALL=C.UTF-8 ' 569 | 'python3 {} --tbname={} --profile {} --imap_pw {} send_receive ' 570 | '--encrypted --signed --inline 2>&1'.format( 571 | self.scriptpath, self.tb_name, self.profile_dir, self.imap_pw), 572 | passio_popen=True) 573 | (stdout, _) = p.communicate() 574 | self.assertEqual(p.returncode, 0, 575 | 'Thunderbird send/receive failed: {}'.format( 576 | stdout.decode('ascii', 'ignore'))) 577 | 578 | def test_020_send_receive_inline_with_attachment(self): 579 | p = self.frontend.run( 580 | 'LC_ALL=C.UTF-8 ' 581 | 'python3 {} --tbname={} --profile {} --imap_pw {} send_receive ' 582 | '--encrypted --signed --inline --with-attachment 2>&1'.format( 583 | self.scriptpath, self.tb_name, self.profile_dir, self.imap_pw), 584 | passio_popen=True) 585 | (stdout, _) = p.communicate() 586 | self.assertEqual(p.returncode, 0, 587 | 'Thunderbird send/receive failed: {}'.format( 588 | stdout.decode('ascii', 'ignore'))) 589 | 590 | 591 | class TC_20_Evolution(SplitGPGBase): 592 | 593 | scriptpath = '/usr/lib/qubes-gpg-split/test_evolution.py' 594 | 595 | def setUp(self): 596 | if self.template.startswith('whonix-gw'): 597 | self.skipTest('whonix-gw template not supported by this test') 598 | super(TC_20_Evolution, self).setUp() 599 | self.frontend.run_service('qubes.WaitForSession', wait=True, 600 | input='user') 601 | if self.frontend.run('which evolution', wait=True) != 0: 602 | self.skipTest('Evolution not installed') 603 | 604 | p = self.frontend.run('gsettings set org.gnome.desktop.interface ' 605 | 'toolkit-accessibility true', wait=True) 606 | assert p == 0, 'Failed to enable accessibility toolkit' 607 | if self.frontend.run( 608 | 'ls {}'.format(self.scriptpath), wait=True): 609 | self.skipTest('qubes-gpg-split-tests package not installed') 610 | 611 | # run as root to not deal with /var/mail permission issues 612 | self.frontend.run( 613 | 'mkdir -p Mail/new Mail/cur Mail/tmp', 614 | wait=True) 615 | self.smtp_server = self.frontend.run( 616 | 'aiosmtpd -n -c aiosmtpd.handlers.Mailbox /home/user/Mail', 617 | passio_popen=True) 618 | 619 | p = self.frontend.run( 620 | 'python3 {} setup 2>&1'.format( 621 | self.scriptpath), 622 | passio_popen=True) 623 | (stdout, _) = p.communicate() 624 | assert p.returncode == 0, 'Evolution setup failed: {}'.format( 625 | stdout.decode('ascii', 'ignore')) 626 | 627 | def tearDown(self): 628 | self.smtp_server.terminate() 629 | del self.smtp_server 630 | super(TC_20_Evolution, self).tearDown() 631 | 632 | def test_000_send_receive_signed_encrypted(self): 633 | p = self.frontend.run( 634 | 'python3 {} send_receive ' 635 | '--encrypted --signed 2>&1'.format( 636 | self.scriptpath), 637 | passio_popen=True) 638 | (stdout, _) = p.communicate() 639 | self.assertEqual(p.returncode, 0, 640 | 'Evolution send/receive failed: {}'.format( 641 | stdout.decode('ascii', 'ignore'))) 642 | 643 | def test_010_send_receive_signed_only(self): 644 | p = self.frontend.run( 645 | 'python3 {} send_receive ' 646 | '--signed 2>&1'.format( 647 | self.scriptpath), 648 | passio_popen=True) 649 | (stdout, _) = p.communicate() 650 | self.assertEqual(p.returncode, 0, 651 | 'Evolution send/receive failed: {}'.format( 652 | stdout.decode('ascii', 'ignore'))) 653 | 654 | @unittest.skip('handling attachments not done') 655 | def test_020_send_receive_with_attachment(self): 656 | p = self.frontend.run( 657 | 'python3 {} send_receive ' 658 | '--encrypted --signed --with-attachment 2>&1'.format( 659 | self.scriptpath), 660 | passio_popen=True) 661 | (stdout, _) = p.communicate() 662 | self.assertEqual(p.returncode, 0, 663 | 'Evolution send/receive failed: {}'.format( 664 | stdout.decode('ascii', 'ignore'))) 665 | 666 | def list_tests(): 667 | return ( 668 | TC_00_Direct, 669 | TC_10_Thunderbird, 670 | TC_20_Evolution 671 | ) 672 | -------------------------------------------------------------------------------- /tests/test_evolution.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # -*- encoding: utf-8 -*- 3 | # 4 | # The Qubes OS Project, http://www.qubes-os.org 5 | # 6 | # Copyright (C) 2018 Marek Marczykowski-Górecki 7 | # 8 | # 9 | # This program is free software; you can redistribute it and/or modify 10 | # it under the terms of the GNU General Public License as published by 11 | # the Free Software Foundation; either version 2 of the License, or 12 | # (at your option) any later version. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License along 20 | # with this program; if not, see . 21 | import argparse 22 | 23 | from dogtail import tree, predicate 24 | from dogtail.config import config 25 | import subprocess 26 | import os 27 | import time 28 | 29 | subject = 'Test message {}'.format(os.getpid()) 30 | 31 | config.actionDelay = 0.5 32 | config.searchCutoffCount = 10 33 | 34 | 35 | def run(cmd): 36 | env = os.environ.copy() 37 | env['GTK_MODULES'] = 'gail:atk-bridge' 38 | return subprocess.Popen([cmd], stdin=subprocess.DEVNULL, 39 | stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, env=env) 40 | 41 | 42 | def get_app(): 43 | config.searchCutoffCount = 30 44 | app = tree.root.application('evolution') 45 | config.searchCutoffCount = 10 46 | return app 47 | 48 | def open_preferences(app): 49 | edit = app.menu('Edit') 50 | edit.menuItem('Preferences').doActionNamed('click') 51 | 52 | def open_accounts(app): 53 | open_preferences(app) 54 | settings = app.window('Evolution Preferences') 55 | accounts_tab = settings.child('Account Name').parent.parent 56 | return settings, accounts_tab 57 | 58 | def get_sibling_offset(node, offset): 59 | return node.parent.children[node.indexInParent+offset] 60 | 61 | def get_sibling_button_maybe(button): 62 | try: 63 | # if there is a sibling button (without the name) that's the one that works 64 | button_sibling = button.parent.children[button.indexInParent + 1] 65 | if button_sibling.roleName == "push button": 66 | return button_sibling 67 | except KeyError: 68 | pass 69 | return button 70 | 71 | def add_local_account(app): 72 | accounts_tab = None 73 | settings = None 74 | try: 75 | wizard = app.childNamed('Welcome') 76 | except tree.SearchError: 77 | settings, accounts_tab = open_accounts(app) 78 | accounts_tab.button('Add').doActionNamed('click') 79 | wizard = app.window('Welcome') 80 | # Welcome tab 81 | wizard.button('Next').doActionNamed('click') 82 | # Restore from backup, if launched from startup wizard 83 | if wizard.name == 'Restore from Backup': 84 | wizard.button('Next').doActionNamed('click') 85 | # Identity tab 86 | wizard.childLabelled('Full Name:').text = 'Test' 87 | wizard.childLabelled('Email Address:').text = 'user@localhost' 88 | wizard.button('Next').doActionNamed('click') 89 | # Receiving Email tab 90 | time.sleep(2) 91 | wizard.menuItem('Maildir-format mail directories').doActionNamed('click') 92 | wizard.childLabelled('Mail Directory:', showingOnly=True).parent.menuItem('Other…').\ 93 | doActionNamed('click') 94 | file_chooser = app.child('Choose a Maildir mail directory', 95 | roleName='file chooser') 96 | file_chooser.child('File System Root').doActionNamed('click') 97 | file_chooser.child('home').doActionNamed('activate') 98 | file_chooser.child('user').doActionNamed('activate') 99 | file_chooser.child('Mail').doActionNamed('activate') 100 | file_chooser.button('Open').doActionNamed('click') 101 | time.sleep(1) 102 | wizard.button('Next').doActionNamed('click') 103 | # Receiving Options tab 104 | wizard.button('Next').doActionNamed('click') 105 | # Sending Email tab 106 | sending = wizard.child('Sending Email', 107 | roleName=wizard.children[0].roleName) 108 | sending.childLabelled('Server:').text = 'localhost' 109 | sending.childLabelled('Port:').child(roleName='text').text = '8025' 110 | encryption = sending.childLabelled('Encryption method:') 111 | if encryption.name != 'No encryption': 112 | encryption.combovalue = 'No encryption' 113 | wizard.button('Next').doActionNamed('click') 114 | # Account Summary tab 115 | wizard.button('Next').doActionNamed('click') 116 | # Done tab 117 | wizard.button('Apply').doActionNamed('click') 118 | 119 | if not accounts_tab: 120 | settings, accounts_tab = open_accounts(app) 121 | # this selects the entry 122 | accounts_tab.child('user@localhost').doActionNamed('edit') 123 | # this open account settings 124 | accounts_tab.child('user@localhost').doActionNamed('activate') 125 | 126 | account = app.dialog('Account Editor') 127 | key_id = account.childLabelled('OpenPGP Key ID:') 128 | try: 129 | key_id = key_id.child(roleName='text') 130 | except tree.SearchError: 131 | pass 132 | key_id.text = 'user@localhost' 133 | account.button('OK').doActionNamed('click') 134 | 135 | # "modern" dialogs lack 'Close' button, and dogtail seems to not support 136 | # sending window close action; use xdotool as a workaround 137 | subprocess.call(['xdotool', 'search', settings.name, 'windowclose']) 138 | 139 | 140 | def attach(app, compose_window, path): 141 | compose_window.button('Add Attachment...').doActionNamed('click') 142 | # TODO: this fails, for some reason dogtail consider 'app' dead, 143 | # while the file chooser dialog can be inspected with sniff without any 144 | # problem 145 | file_chooser = app.child('Add Attachment', roleName='file chooser') 146 | file_chooser.child('Home').doActionNamed('click') 147 | file_chooser.child(os.path.basename(path)).doActionNamed('activate') 148 | file_chooser.button('Attach').doActionNamed('click') 149 | 150 | def send_email(app, sign=False, encrypt=False, inline=False, attachment=None): 151 | new_button = app.button('New') 152 | new_button = get_sibling_button_maybe(new_button) 153 | new_button.doActionNamed('click') 154 | new_message = app.child('Compose Message', roleName='frame') 155 | new_message.textentry('To:').text = 'user@localhost,' 156 | new_message.childLabelled('Subject:').text = subject 157 | compose_document = new_message.child( 158 | roleName='document web') 159 | compose_document.click() 160 | compose_document.typeText('This is test message') 161 | if encrypt: 162 | new_message.menu('Options').menuItem('PGP Encrypt').doActionNamed( 163 | 'click') 164 | if sign: 165 | new_message.menu('Options').menuItem('PGP Sign').doActionNamed('click') 166 | if inline: 167 | raise NotImplementedError( 168 | 'toggling inline pgp not supported for evolution') 169 | if attachment: 170 | attach(app, new_message, attachment) 171 | 172 | new_message.button('Send').doActionNamed('click') 173 | 174 | def receive_message(app, signed=False, encrypted=False, attachment=None): 175 | send_receive = app.button('Send / Receive') 176 | send_receive = get_sibling_button_maybe(send_receive) 177 | send_receive.doActionNamed('click') 178 | app.child(name='Inbox .*', roleName='table cell').doActionNamed('edit') 179 | messages = app.child('Messages', roleName='panel') 180 | messages.child(subject).grabFocus() 181 | message = app.child('Evolution Mail Display', roleName='document web') 182 | msg_body = message.child('.message.*', roleName='document web')\ 183 | .children[0].text 184 | print('Message body: "{}"'.format(msg_body)) 185 | assert msg_body.strip() == 'This is test message' 186 | 187 | try: 188 | gpg_header = message.findChildren(lambda cell: 189 | cell.roleName == 'row header' and 'Security' in cell.text)[0] 190 | gpg_info = gpg_header.parent[gpg_header.indexInParent+1].text 191 | except (tree.SearchError, IndexError): 192 | # From, To, Subject, Date, Security 193 | gpg_info = message.findChildren( 194 | predicate.GenericPredicate(roleName='table cell'))[4].text 195 | print('GPG info: {}'.format(gpg_info)) 196 | 197 | if signed: 198 | assert 'signed' in gpg_info 199 | if encrypted: 200 | assert 'encrypted' in gpg_info 201 | 202 | if attachment: 203 | # check if attachment is present 204 | messages.parent.parent.child(os.path.basename(attachment)) 205 | messages.parent.parent.button('Save As').doActionNamed('click') 206 | save_dialog = app.child('Save Attachment', roleName='frame') 207 | saved_basepath = os.path.expanduser('~/Desktop/{}'.format( 208 | os.path.basename(attachment))) 209 | save_dialog.child(roleName='text').text = saved_basepath 210 | save_dialog.button('Save').doActionNamed('click') 211 | 212 | time.sleep(1) 213 | with open(attachment, 'r') as f: 214 | orig_attachment = f.read() 215 | if os.path.exists(saved_basepath): 216 | with open(saved_basepath) as f: 217 | received_attachment = f.read() 218 | assert received_attachment == orig_attachment 219 | print("Attachment content ok") 220 | else: 221 | raise Exception('Attachment {} not found'.format(saved_basepath)) 222 | 223 | def quit(app): 224 | app.menu('File').menuItem('Quit').doActionNamed('click') 225 | 226 | def main(): 227 | parser = argparse.ArgumentParser() 228 | parser.add_argument('--exe', help='Evolution executable name', 229 | default='evolution') 230 | subparsers = parser.add_subparsers(dest='command') 231 | subparsers.add_parser('setup', help='setup Evolution for tests') 232 | parser_send_receive = subparsers.add_parser('send_receive', 233 | help='send and receive an email') 234 | parser_send_receive.add_argument('--encrypted', action='store_true', 235 | default=False) 236 | parser_send_receive.add_argument('--signed', action='store_true', 237 | default=False) 238 | parser_send_receive.add_argument('--inline', action='store_true', 239 | default=False) 240 | parser_send_receive.add_argument('--with-attachment', 241 | action='store_true', default=False) 242 | args = parser.parse_args() 243 | 244 | # log only to stdout since logging to file have broken unicode support 245 | config.logDebugToFile = False 246 | 247 | if args.command == 'setup': 248 | subprocess.check_call([ 249 | 'gsettings', 'set', 'org.gnome.evolution-data-server', 250 | 'camel-gpg-binary', '/usr/bin/qubes-gpg-client-wrapper']) 251 | subprocess.check_call([ 252 | 'gsettings', 'set', 'org.gnome.evolution.mail', 253 | 'prompt-check-if-default-mailer', 'false']) 254 | proc = run(args.exe) 255 | app = get_app() 256 | add_local_account(app) 257 | if args.command == 'send_receive': 258 | app = get_app() 259 | if args.with_attachment: 260 | attachment = '/home/user/attachment{}.txt'.format(os.getpid()) 261 | with open(attachment, 'w') as f: 262 | f.write('This is test attachment content') 263 | else: 264 | attachment = None 265 | send_email(app, sign=args.signed, encrypt=args.encrypted, inline=args.inline, 266 | attachment=attachment) 267 | time.sleep(5) 268 | receive_message(app, signed=args.signed, encrypted=args.encrypted, 269 | attachment=attachment) 270 | quit(app) 271 | 272 | if __name__ == '__main__': 273 | main() 274 | -------------------------------------------------------------------------------- /tests/test_thunderbird.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python3 2 | # vim: fileencoding=utf-8 3 | 4 | # 5 | # The Qubes OS Project, https://www.qubes-os.org/ 6 | # 7 | # Copyright (C) 2016 Marek Marczykowski-Górecki 8 | # 9 | # 10 | # This program is free software; you can redistribute it and/or modify 11 | # it under the terms of the GNU General Public License as published by 12 | # the Free Software Foundation; either version 2 of the License, or 13 | # (at your option) any later version. 14 | # 15 | # This program is distributed in the hope that it will be useful, 16 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | # GNU General Public License for more details. 19 | # 20 | # You should have received a copy of the GNU General Public License along 21 | # with this program; if not, write to the Free Software Foundation, Inc., 22 | # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 23 | # 24 | import argparse 25 | 26 | from dogtail import tree 27 | from dogtail.predicate import GenericPredicate, Predicate 28 | from dogtail.config import config 29 | from dogtail.rawinput import click, doubleClick, keyCombo 30 | import subprocess 31 | import os 32 | import time 33 | import functools 34 | 35 | subject = 'Test message {}'.format(os.getpid()) 36 | 37 | defaultCutoffCount = 10 38 | 39 | config.actionDelay = 1.0 40 | config.defaultDelay = 1.0 41 | config.searchCutoffCount = defaultCutoffCount 42 | 43 | 44 | class orPredicate(Predicate): 45 | def __init__(self, *predicates): 46 | self.predicates = predicates 47 | self.satisfiedByNode = self._genCompareFunc() 48 | 49 | def _genCompareFunc(self): 50 | funcs = [p.satisfiedByNode for p in self.predicates] 51 | 52 | def satisfiedByNode(node): 53 | return any(f(node) for f in funcs) 54 | 55 | return satisfiedByNode 56 | 57 | def describeSearchResult(self): 58 | return ' or '.join(p.describeSearchResult() for p in self.predicates) 59 | 60 | class Thunderbird: 61 | """ 62 | Manages the state of a thunderbird instance 63 | """ 64 | 65 | def __init__(self, tb_name, profile_dir, imap_pw): 66 | self.name = tb_name 67 | self.tb_cmd = [self.name] 68 | if profile_dir: 69 | self.tb_cmd.append('--profile') 70 | self.tb_cmd.append(profile_dir) 71 | self.imap_pw = imap_pw 72 | self.start() 73 | 74 | def start(self): 75 | env = os.environ.copy() 76 | env['GTK_MODULES'] = 'gail:atk-bridge' 77 | null = open(os.devnull, 'r+') 78 | self.process = subprocess.Popen( 79 | self.tb_cmd, stdout=null, stdin=null, stderr=null, env=env) 80 | self.app = self._get_app() 81 | 82 | def _get_app(self): 83 | config.searchCutoffCount = 70 84 | tb = tree.root.application('Thunderbird|Icedove') 85 | config.searchCutoffCount = defaultCutoffCount 86 | return tb 87 | 88 | def get_version(self): 89 | try: 90 | res = subprocess.check_output([self.name, '--version']) 91 | version = res.decode('utf-8').replace('Thunderbird ', '').split('.')[0] 92 | except (subprocess.SubprocessError, IndexError): 93 | raise Exception('Cannot determine version') 94 | return int(version) 95 | 96 | def quit(self): 97 | self.app.menu('File').doActionNamed('click') 98 | self.app.menu('File').menuItem('Quit').doActionNamed('click') 99 | self.process.wait() 100 | 101 | def kill(self): 102 | self.process.terminate() 103 | 104 | 105 | def retry_if_failed(max_tries): 106 | """ Decorator that repeats a function if any exception is thrown. Assumes 107 | the decorated function is generally idempotent (i.e. if ran multiple times 108 | consecutively or multiple partial times it leads to the same result as if 109 | ran only once) 110 | """ 111 | 112 | def decorator(func): 113 | @functools.wraps(func) 114 | def wrapper(*args, **kwargs): 115 | tb = args[0] 116 | 117 | for retry in range(0, max_tries): 118 | try: 119 | func(*args, **kwargs) 120 | break # if successful 121 | except Exception as e: 122 | if retry == max_tries-1: 123 | raise e 124 | else: 125 | print("failed during setup in {}.\n Retrying".format( 126 | func.__name__)) 127 | tb.kill() 128 | tb.start() 129 | return wrapper 130 | return decorator 131 | 132 | 133 | def get_key_fpr(): 134 | try: 135 | cmd = '/usr/bin/qubes-gpg-client-wrapper -K --with-colons' 136 | res = subprocess.check_output(cmd.split(' ')) 137 | keyid = res.decode('utf-8').split('\n')[1] 138 | keyid = keyid.split(':')[9] 139 | keyid = keyid[-16:] 140 | except (subprocess.SubprocessError, IndexError): 141 | raise Exception('Cannot determine keyid') 142 | return keyid 143 | 144 | 145 | def export_pub_key(): 146 | try: 147 | cmd = '/usr/bin/qubes-gpg-client-wrapper --armor --export --output /home/user/pub.asc' 148 | subprocess.check_output(cmd.split(' ')) 149 | except subprocess.SubprocessError: 150 | raise Exception('Cannot export public key') 151 | 152 | @retry_if_failed(max_tries=3) 153 | def enter_imap_passwd(tb): 154 | try: 155 | pass_prompt = tb.app.findChild(orPredicate( 156 | GenericPredicate(name='Enter your password for user', roleName='frame'), 157 | GenericPredicate(name='Enter your password for user', roleName='dialog') 158 | )) 159 | except tree.SearchError: 160 | # check new mail so client can realize IMAP requires entering a password 161 | get_messages(tb, button_only=True) 162 | # password entry 163 | pass_prompt = tb.app.findChild(orPredicate( 164 | GenericPredicate(name='Enter your password for user', roleName='frame'), 165 | GenericPredicate(name='Enter your password for user', roleName='dialog') 166 | )) 167 | pass_textbox = pass_prompt.findChild(GenericPredicate(roleName='password text')) 168 | pass_textbox.typeText(tb.imap_pw) 169 | pass_prompt.childNamed("Use Password Manager to remember this password.")\ 170 | .doActionNamed('check') 171 | pass_prompt.findChild(orPredicate( 172 | GenericPredicate(name='OK', roleName='push button'), # tb < 91 173 | GenericPredicate(name='Sign in', roleName='push button'), # tb >= 91, tb < 128 174 | GenericPredicate(name='OK', roleName='button')) # tb >= 128 175 | ).doActionNamed('press') 176 | 177 | def accept_qubes_attachments(tb): 178 | try: 179 | qubes_att = tb.app.child(name='Qubes Attachments added', roleName='label') 180 | # give it some time to settle 181 | time.sleep(3) 182 | qubes_att.parent.button('Enable').doActionNamed('press') 183 | 184 | qubes_att = tb.app.child(name='Qubes Attachments has been added.*', roleName='label') 185 | # give it some time to settle 186 | time.sleep(3) 187 | qubes_att.parent.button('Not now').doActionNamed('press') 188 | except tree.SearchError: 189 | pass 190 | config.searchCutoffCount = defaultCutoffCount 191 | 192 | 193 | def open_account_setup(tb): 194 | edit = tb.app.menu('Edit') 195 | edit.doActionNamed('click') 196 | account_settings = edit.menuItem('Account Settings') 197 | account_settings.doActionNamed('click') 198 | 199 | def close_account_setup(tb): 200 | file = tb.app.menu('File') 201 | file.doActionNamed('click') 202 | file.child('Close').doActionNamed('click') 203 | 204 | class TBEntry(GenericPredicate): 205 | def __init__(self, name): 206 | super(TBEntry, self).__init__(name=name, roleName='entry') 207 | 208 | def show_menu_bar(tb): 209 | config.searchCutoffCount = 20 210 | app = tb.app.findChild( 211 | GenericPredicate(name='Application', roleName='menu bar')) 212 | app.findChild(GenericPredicate( 213 | name='View', roleName='menu')).doActionNamed('click') 214 | app.findChild(GenericPredicate( 215 | name='Toolbars', roleName='menu')).doActionNamed('click') 216 | app.findChild(GenericPredicate( 217 | name='Menu Bar', roleName='check menu item')).doActionNamed('click') 218 | config.searchCutoffCount = defaultCutoffCount 219 | 220 | @retry_if_failed(max_tries=3) 221 | def configure_openpgp_account(tb): 222 | keyid = get_key_fpr() 223 | export_pub_key() 224 | tb.app.findChild(GenericPredicate( 225 | name='Tools', roleName='menu')).doActionNamed('click') 226 | tb.app.findChild(GenericPredicate( 227 | name='OpenPGP Key Manager', roleName='menu item')).doActionNamed('click') 228 | key_manager = tb.app.findChild(orPredicate( 229 | GenericPredicate(name='OpenPGP Key Manager', roleName='dialog'), 230 | GenericPredicate(name='OpenPGP Key Manager', roleName='frame'))) 231 | key_manager.findChild( 232 | GenericPredicate(name='File', roleName='menu')).doActionNamed('click') 233 | key_manager.findChild( 234 | GenericPredicate(name='Import Public Key(s) From File', 235 | roleName='menu item')).doActionNamed('click') 236 | file_chooser = tb.app.findChild(GenericPredicate(name='Import OpenPGP Key File', 237 | roleName='file chooser')) 238 | # wait for dialog to completely initialize, otherwise it may try to click 239 | # on "Home" before it is active. 240 | time.sleep(1) 241 | click(*file_chooser.childNamed('Home').position) 242 | click(*file_chooser.childNamed('pub.asc').position) 243 | file_chooser.childNamed('Open').doActionNamed('click') 244 | accept_dialog = tb.app.findChild(orPredicate( 245 | GenericPredicate(name='.*(%s).*' % keyid), 246 | GenericPredicate(name='.[0-9A-F]*%s' % keyid), 247 | GenericPredicate(name='ID: 0x%s' % keyid), 248 | )).parent.parent 249 | try: 250 | accept_dialog.childNamed("Accepted.*").doActionNamed("select") 251 | except tree.SearchError: 252 | # old TB 253 | pass 254 | accept_dialog.childNamed('OK|Import').doActionNamed('press') 255 | tb.app.childNamed('Success! Keys imported.*').childNamed('OK').doActionNamed( 256 | 'press') 257 | doubleClick(*key_manager.findChild( 258 | GenericPredicate(name='Qubes test .*')).position) 259 | key_property = tb.app.findChild(orPredicate( 260 | GenericPredicate(name="Key Properties.*", roleName='frame'), 261 | GenericPredicate(name="Key Properties.*", roleName='dialog'))) 262 | key_property.findChild( 263 | GenericPredicate(name="Yes, I['’]ve verified in person.*", 264 | roleName='radio button')).doActionNamed('select') 265 | key_property.childNamed('OK').doActionNamed('press') 266 | key_manager.findChild( 267 | GenericPredicate(name='Close', roleName='menu item')).doActionNamed( 268 | 'click') 269 | 270 | 271 | def get_messages(tb, button_only=False): 272 | try: 273 | # TB >= 115 274 | try: 275 | # TB >= 128 276 | tb.app.child('Get Messages', roleName='button').doActionNamed('press') 277 | except tree.SearchError: 278 | # TB < 128 279 | tb.app.button('Get Messages').doActionNamed('press') 280 | if button_only: 281 | return 282 | tb.app.child(name='Inbox.*', roleName='tree item').doActionNamed( 283 | 'activate') 284 | except tree.SearchError: 285 | # TB < 115 286 | tb.app.child(name='user@localhost', 287 | roleName='table row').doActionNamed('activate') 288 | tb.app.button('Get Messages').doActionNamed('press') 289 | tb.app.menuItem('Get All New Messages').doActionNamed('click') 290 | if button_only: 291 | return 292 | tb.app.child(name='Inbox.*', roleName='table row').doActionNamed( 293 | 'activate') 294 | 295 | 296 | def attach(tb, compose_window, path): 297 | try: 298 | # TB >= 128 299 | compose_window.child('Attach', roleName='button').\ 300 | doActionNamed('press') 301 | compose_window.child('Attach', roleName='button').\ 302 | menuItem('File.*').doActionNamed('click') 303 | except tree.SearchError: 304 | # TB < 128 305 | compose_window.button('Attach').button('Attach').doActionNamed('press') 306 | compose_window.button('Attach').menuItem('File.*').doActionNamed('click') 307 | # for some reason on some thunderbird versions do not expose 'Attach File' 308 | # dialog through accessibility API, use xdotool instead 309 | subprocess.check_call( 310 | ['xdotool', 'search', '--sync', '--name', 'Attach File.*', 311 | 'key', '--window', '0', 'ctrl+l', 312 | 'sleep', '1', 313 | 'type', '--window', '%1', path]) 314 | time.sleep(1) 315 | subprocess.check_call( 316 | ['xdotool', 'search', '--name', 'Attach File.*', 'key', 'Return']) 317 | time.sleep(1) 318 | # select_file = tb.app.dialog('Attach File.*') 319 | # places = select_file.child(roleName='table', 320 | # name='Places') 321 | # places.child(name='Desktop').click() 322 | # location_toggle = select_file.child(roleName='toggle button', 323 | # name='Type a file name') 324 | # if not location_toggle.checked: 325 | # location_toggle.doActionNamed('click') 326 | # location_label = select_file.child(name='Location:', roleName='label') 327 | # location = location_label.parent.children[location_label.indexInParent + 1] 328 | # location.text = path 329 | # select_file.button('Open').doActionNamed('click') 330 | 331 | 332 | def send_email(tb, sign=False, encrypt=False, inline=False, attachment=None): 333 | config.searchCutoffCount = 20 334 | try: 335 | # TB >= 128 336 | write = tb.app.child(name='New Message', roleName='button') 337 | except tree.SearchError: 338 | try: 339 | write = tb.app.button('New Message') 340 | except tree.SearchError: 341 | write = tb.app.button('Write') 342 | config.searchCutoffCount = defaultCutoffCount 343 | write.doActionNamed('press') 344 | compose = tb.app.child(name='Write: .*', roleName='frame') 345 | to_entry = compose.findChild(TBEntry(name='To')) 346 | to_entry.typeText('user@localhost') 347 | # lets thunderbird settle down on default values (after filling recipients) 348 | time.sleep(1) 349 | subject_entry = compose.findChild( 350 | orPredicate(GenericPredicate(name='Subject:', roleName='entry'), 351 | TBEntry(name='Subject'))) 352 | subject_entry.typeText(subject) 353 | try: 354 | compose_document = compose.child(roleName='document web') 355 | try: 356 | compose_document.parent.doActionNamed('click') 357 | except tree.ActionNotSupported: 358 | pass 359 | compose_document.typeText('This is test message') 360 | except tree.SearchError: 361 | compose.child( 362 | roleName='document frame').text = 'This is test message' 363 | try: 364 | # TB >= 128 365 | security = compose.findChild( 366 | GenericPredicate(name='Security|OpenPGP', roleName='button')) 367 | except tree.SearchError: 368 | # TB < 128 369 | security = compose.findChild( 370 | GenericPredicate(name='Security|OpenPGP', roleName='push button')) 371 | security.doActionNamed('press') 372 | sign_button = security.childNamed('Digitally Sign.*') 373 | encrypt_button = security.childNamed('Require Encryption|Encrypt') 374 | if sign_button.checked != sign: 375 | sign_button.doActionNamed('click') 376 | if encrypt_button.checked != encrypt: 377 | encrypt_button.doActionNamed('click') 378 | if attachment: 379 | attach(tb, compose, attachment) 380 | try: 381 | # TB >= 128 382 | compose.child('Send', roleName='button').doActionNamed('press') 383 | except tree.SearchError: 384 | # TB < 128 385 | compose.button('Send').doActionNamed('press') 386 | config.searchCutoffCount = 5 387 | try: 388 | if encrypt: 389 | tb.app.dialog('Enable Protection of Subject?'). \ 390 | button('Protect subject').doActionNamed('press') 391 | except tree.SearchError: 392 | pass 393 | finally: 394 | config.searchCutoffCount = defaultCutoffCount 395 | 396 | # Fail if something like a dialog box prevented the compose window to be 397 | # closed. Means no email was actually sent. 398 | config.searchCutoffCount = 1 399 | timeout = 40 # the "showing" state usually updates after 7 ~ 25 seconds 400 | failed_sending = False 401 | error_message="unknown" 402 | while compose.showing: 403 | timeout -= 1 404 | if timeout <= 0: 405 | failed_sending = True 406 | try: 407 | dialog = tb.app.dialog('.*') 408 | error_message = dialog.child(roleName='label').text 409 | if error_message != 'Status:': 410 | failed_sending = True 411 | except tree.SearchError: 412 | pass 413 | if failed_sending: 414 | raise Exception("Failed to send message with error '{}'"\ 415 | .format(error_message)) 416 | time.sleep(1) 417 | config.searchCutoffCount = defaultCutoffCount 418 | 419 | 420 | def receive_message(tb, signed=False, encrypted=False, attachment=None): 421 | get_messages(tb) 422 | if encrypted: 423 | config.searchCutoffCount = 5 424 | try: 425 | # TB >= 128 426 | tb.app.child(name='user[^,]*, .*, \.\.\..*', 427 | roleName='table row').doActionNamed('clickAncestor') 428 | except tree.SearchError: 429 | try: 430 | # TB >= 115 431 | tb.app.child(name='user[^,]*, .*, \.\.\..*', 432 | roleName='tree item').doActionNamed('activate') 433 | except tree.SearchError: 434 | # TB < 115 435 | tb.app.child(name='Encrypted Message .*|.*\.\.\. .*', 436 | roleName='table row').doActionNamed('activate') 437 | finally: 438 | config.searchCutoffCount = defaultCutoffCount 439 | try: 440 | # TB >= 128 441 | tb.app.child(name='.*{}.*'.format(subject), 442 | roleName='table row').doActionNamed('clickAncestor') 443 | except tree.SearchError: 444 | try: 445 | # TB >= 115 446 | tb.app.child(name='.*{}.*'.format(subject), 447 | roleName='tree item').doActionNamed('activate') 448 | except tree.SearchError: 449 | # TB < 115 450 | tb.app.child(name='.*{}.*'.format(subject), 451 | roleName='table row').doActionNamed('activate') 452 | # wait a little to TB decrypt/check the message 453 | time.sleep(2) 454 | # dogtail always add '$' at the end of regexp; and also "Escape all 455 | # parentheses, since grouping will never be needed here", so it can't be used 456 | # here either 457 | try: 458 | msg = tb.app.child(roleName='document web', 459 | name=subject + '$|Encrypted Message|\.\.\.') 460 | except tree.SearchError: 461 | msg = tb.app.child(roleName='document frame', 462 | name=subject + '$|Encrypted Message|\.\.\.') 463 | try: 464 | msg = msg.child(roleName='section') 465 | if len(msg.text) < 5 and msg.children: 466 | msg = msg.children[0] 467 | except tree.SearchError: 468 | msg = msg.child(roleName='paragraph') 469 | msg_body = msg.text 470 | print('Message body: {}'.format(msg_body)) 471 | assert msg_body.strip() == 'This is test message' 472 | # if msg.children: 473 | # msg_body = msg.children[0].text 474 | # else: 475 | # msg_body = msg.text 476 | config.searchCutoffCount = 5 477 | try: 478 | if signed or encrypted: 479 | tb.app.button('OpenPGP.*').doActionNamed('press') 480 | # 'Message Security - OpenPGP' is an internal label, 481 | # nested 2 levels into the popup 482 | message_security = tb.app.child('Message Security - OpenPGP') 483 | except tree.SearchError: 484 | # alternative way of opening 'message security' 485 | keyCombo('s') 486 | message_security = tb.app.child('Message Security - OpenPGP') 487 | message_security = message_security.parent.parent 488 | try: 489 | if signed: 490 | message_security.child('Good Digital Signature') 491 | if encrypted: 492 | message_security.child('Message Is Encrypted') 493 | except tree.SearchError: 494 | if signed or encrypted: 495 | raise 496 | message_security.parent.click() 497 | config.searchCutoffCount = defaultCutoffCount 498 | 499 | if attachment: 500 | # it can be either "1 attachment:" or "2 attachments" 501 | attachment_label = tb.app.child(name='.* attachment[:s]', roleName='label') 502 | offset = 0 503 | if attachment_label.name == '1 attachment:': 504 | offset += 1 505 | attachment_size = attachment_label.parent.children[ 506 | attachment_label.indexInParent + 1 + offset] 507 | assert attachment_size.text[0] != '0' 508 | attachment_save_parent = attachment_label.parent.children[ 509 | attachment_label.indexInParent + 2 + offset] 510 | try: 511 | # TB >= 128 512 | attachment_save = attachment_save_parent.child('Save.*', roleName='button') 513 | except tree.SearchError: 514 | # TB < 128 515 | attachment_save = attachment_save_parent.button('Save.*') 516 | try: 517 | # try child button first 518 | attachment_save.children[1].doActionNamed('press') 519 | except IndexError: 520 | # otherwise press main button to open the menu 521 | attachment_save.doActionNamed('press') 522 | # and choose "Save As..." 523 | attachment_save.menuItem('Save As.*|Save All.*').doActionNamed( 524 | 'click') 525 | # for some reasons some Thunderbird versions do not expose 'Attach File' 526 | # dialog through accessibility API, use xdotool instead 527 | save_as = tb.app.findChild( 528 | GenericPredicate(name='Save All Attachments|Save Attachment', 529 | roleName='file chooser')) 530 | click(*save_as.childNamed('Home').position) 531 | click(*save_as.childNamed('Desktop').position) 532 | if save_as.name == 'Save Attachment': 533 | save_as.childNamed('Save').doActionNamed('click') 534 | else: 535 | save_as.childNamed('Open').doActionNamed('click') 536 | # save_as = tb.app.dialog('Save .*Attachment.*') 537 | # places = save_as.child(roleName='table', 538 | # name='Places') 539 | # places.child(name='Desktop').click() 540 | # if 'attachments' in attachment_label.text: 541 | # save_as.button('Open').doActionNamed('click') 542 | # else: 543 | # save_as.button('Save').doActionNamed('click') 544 | time.sleep(1) 545 | with open(attachment, 'r') as f: 546 | orig_attachment = f.read() 547 | saved_basepath = os.path.expanduser('~/Desktop/{}'.format( 548 | os.path.basename(attachment))) 549 | if os.path.exists(saved_basepath): 550 | with open(saved_basepath) as f: 551 | received_attachment = f.read() 552 | assert received_attachment == orig_attachment 553 | print("Attachment content ok") 554 | elif os.path.exists(saved_basepath + '.pgp'): 555 | p = subprocess.Popen(['qubes-gpg-client-wrapper'], 556 | stdout=subprocess.PIPE, stderr=subprocess.PIPE, 557 | stdin=open(saved_basepath + '.pgp', 'r')) 558 | (stdout, stderr) = p.communicate() 559 | if signed: 560 | if b'Good signature' not in stderr: 561 | print(stderr.decode()) 562 | raise AssertionError('no good signature found') 563 | print("Attachment signature ok") 564 | assert stdout.decode() == orig_attachment 565 | print("Attachment content ok - encrypted") 566 | else: 567 | raise Exception('Attachment {} not found'.format(saved_basepath)) 568 | if os.path.exists(saved_basepath + '.sig'): 569 | p = subprocess.Popen(['qubes-gpg-client-wrapper', '--verify', 570 | saved_basepath + '.sig', saved_basepath], 571 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 572 | (stdout, stderr) = p.communicate() 573 | if signed: 574 | if b'Good signature' not in stderr: 575 | print(stderr.decode()) 576 | raise AssertionError('no good signature found') 577 | print("Attachment detached signature ok") 578 | 579 | # tb.app.button('Delete').doActionNamed('press') 580 | 581 | 582 | def main(): 583 | parser = argparse.ArgumentParser() 584 | parser.add_argument('--tbname', help='Thunderbird executable name', 585 | default='thunderbird') 586 | parser.add_argument('--profile', help='Thunderbird profile path') 587 | parser.add_argument('--imap_pw', help='IMAP password') 588 | subparsers = parser.add_subparsers(dest='command') 589 | subparsers.add_parser('setup', help='setup Thunderbird for tests') 590 | parser_send_receive = subparsers.add_parser( 591 | 'send_receive', help='send and receive an email') 592 | parser_send_receive.add_argument('--encrypted', action='store_true', 593 | default=False) 594 | parser_send_receive.add_argument('--signed', action='store_true', 595 | default=False) 596 | parser_send_receive.add_argument('--inline', action='store_true', 597 | default=False) 598 | parser_send_receive.add_argument('--with-attachment', 599 | action='store_true', default=False) 600 | args = parser.parse_args() 601 | 602 | # log only to stdout since logging to file have broken unicode support 603 | config.logDebugToFile = False 604 | 605 | tb = Thunderbird(args.tbname, args.profile, args.imap_pw) 606 | if args.command == 'setup': 607 | enter_imap_passwd(tb) 608 | accept_qubes_attachments(tb) 609 | show_menu_bar(tb) 610 | configure_openpgp_account(tb) 611 | tb.quit() 612 | if args.command == 'send_receive': 613 | if args.with_attachment: 614 | attachment = '/home/user/attachment{}.txt'.format(os.getpid()) 615 | with open(attachment, 'w') as f: 616 | f.write('This is test attachment content') 617 | else: 618 | attachment = None 619 | send_email(tb, sign=args.signed, encrypt=args.encrypted, 620 | inline=args.inline, attachment=attachment) 621 | time.sleep(5) 622 | 623 | receive_message(tb, signed=args.signed, encrypted=args.encrypted, 624 | attachment=attachment) 625 | tb.quit() 626 | 627 | 628 | if __name__ == '__main__': 629 | main() 630 | -------------------------------------------------------------------------------- /tests/whonix-clock-override.conf: -------------------------------------------------------------------------------- 1 | # Reduces the bootclockrandomization to +/- 5 seconds 2 | # 3 | # Avoids having backwards time leaps which would mess up the tests making 4 | # them unstable. 5 | 6 | [Service] 7 | Environment="delay_plus_or_minus=5" -------------------------------------------------------------------------------- /version: -------------------------------------------------------------------------------- 1 | 2.0.77 2 | --------------------------------------------------------------------------------