diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 26c34bd8da..648cbcb46f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -148,7 +148,6 @@ jobs: - log - proxy - qubesdb-tools - - whonix-config debian_version: - bookworm runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 5a88615476..23b0b8edf8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -962,6 +962,7 @@ dependencies = [ "system-configuration", "tokio", "tokio-native-tls", + "tokio-socks", "tokio-util", "tower-service", "url", @@ -1206,6 +1207,26 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "thiserror" +version = "1.0.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d11abd9594d9b38965ef50805c5e469ca9cc6f197f883f717e0269a3057b3d5" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae71770322cbd277e69d762a16c444af02aa0575ac0d174f0b9562d3b37f8602" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -1253,6 +1274,18 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-socks" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d4770b8024672c1101b3f6733eab95b18007dbe0847a8afe341fcf79e06043f" +dependencies = [ + "either", + "futures-util", + "thiserror", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.10" diff --git a/README.md b/README.md index 67944e8e28..b8ce4f79b3 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,6 @@ This repository contains multiple components, including: * `qubesdb-tools`: tools for configuring non-Qubes-aware applications from QubesDB * `proxy`: restricted HTTP proxy -* `whonix-config`: Whonix configuration for SecureDrop * `workstation-config`: configuration for SecureDrop Workstation templates Each component's folder has a README with more detail. diff --git a/app/integration_tests/proxy.test.ts b/app/integration_tests/proxy.test.ts index 078e613828..c7c652c984 100644 --- a/app/integration_tests/proxy.test.ts +++ b/app/integration_tests/proxy.test.ts @@ -13,7 +13,10 @@ const proxyCommand = (timeout: number): ProxyCommand => { return { command: sdProxyCommand, options: [], - proxyOrigin: sdProxyOrigin, + env: new Map([ + ["SD_PROXY_ORIGIN", sdProxyOrigin], + ["DISABLE_TOR", "yes"], + ]), timeout: timeout as ms, }; }; diff --git a/app/src/main/proxy.test.ts b/app/src/main/proxy.test.ts index 80e9698036..563c2b6ecb 100644 --- a/app/src/main/proxy.test.ts +++ b/app/src/main/proxy.test.ts @@ -29,7 +29,7 @@ const mockProxyCommand = (): ProxyCommand => { return { command: "", options: [], - proxyOrigin: "", + env: new Map(), timeout: 100 as ms, }; }; diff --git a/app/src/main/proxy.ts b/app/src/main/proxy.ts index 3ca2e6f9ab..cfd8821886 100644 --- a/app/src/main/proxy.ts +++ b/app/src/main/proxy.ts @@ -25,9 +25,12 @@ export async function proxy( ): Promise { let command = ""; let commandOptions: string[] = []; + const env: Map = new Map(); if (import.meta.env.MODE == "development") { command = __PROXY_CMD__; + env.set("SD_PROXY_ORIGIN", __PROXY_ORIGIN__); + env.set("DISABLE_TOR", "yes"); } else { command = "/usr/lib/qubes/qrexec-client-vm"; @@ -37,7 +40,7 @@ export async function proxy( const proxyCommand: ProxyCommand = { command: command, options: commandOptions, - proxyOrigin: __PROXY_ORIGIN__, + env: env, timeout: DEFAULT_PROXY_CMD_TIMEOUT_MS, abortSignal: abortSignal, }; @@ -97,7 +100,7 @@ export async function proxyJSONRequest( ): Promise { return new Promise((resolve, reject) => { const process = child_process.spawn(command.command, command.options, { - env: { SD_PROXY_ORIGIN: command.proxyOrigin }, + env: Object.fromEntries(command.env), timeout: command.timeout, signal: command.abortSignal, }); @@ -188,7 +191,7 @@ export async function proxyStreamInner( let stderr = ""; let stdout = ""; const process = child_process.spawn(command.command, command.options, { - env: { SD_PROXY_ORIGIN: command.proxyOrigin }, + env: Object.fromEntries(command.env), timeout: command.timeout, signal: command.abortSignal, }); diff --git a/app/src/types.ts b/app/src/types.ts index a05547f897..615c06ba5b 100644 --- a/app/src/types.ts +++ b/app/src/types.ts @@ -9,7 +9,7 @@ export type ProxyRequest = { export type ProxyCommand = { command: string; options: string[]; - proxyOrigin: string; + env: Map; timeout: ms; abortSignal?: AbortSignal; }; diff --git a/client/README.md b/client/README.md index 819277128c..724bffa678 100644 --- a/client/README.md +++ b/client/README.md @@ -76,11 +76,7 @@ end subgraph sd-proxy spProxy["securedrop-proxy"] end -spProxy --HTTP--> spTor -subgraph sd-whonix -spTor["Tor"] -end -spTor --> spServer["SecureDrop Server"] +spProxy --HTTP over Tor--> spServer["SecureDrop Server"] end ``` diff --git a/client/securedrop_client/sdk/__init__.py b/client/securedrop_client/sdk/__init__.py index 00df337c5e..c83062f08f 100644 --- a/client/securedrop_client/sdk/__init__.py +++ b/client/securedrop_client/sdk/__init__.py @@ -337,6 +337,7 @@ def _send_json_request( env = {} if self.development_mode: env["SD_PROXY_ORIGIN"] = self.server + env["DISABLE_TOR"] = "yes" # Streaming if stream: diff --git a/debian/control b/debian/control index 61879ae1bb..a9db038682 100644 --- a/debian/control +++ b/debian/control @@ -37,7 +37,8 @@ Description: Python module and qrexec service to store logs for SecureDrop Works Package: securedrop-proxy Architecture: any -Depends: ${misc:Depends}, ${shlibs:Depends}, libqubesdb +# TODO: add securedrop-arti once that's published +Depends: ${misc:Depends}, ${shlibs:Depends}, libqubesdb, python3, python3-cryptography, python3-qubesdb Description: This is securedrop Qubes proxy service This package provides the network proxy on Qubes to talk to the SecureDrop server. @@ -49,14 +50,6 @@ Description: Tools for configuring non-Qubes-aware applications from QubesDB. This package provides tools for configuring non-Qubes-aware applications from QubesDB. -Package: securedrop-whonix-config -Section: admin -Architecture: all -# FIXME: s/tor/anon-gw-anonymizer-config/ (requires Whonix repositories in piuparts) -Depends: ${misc:Depends}, securedrop-qubesdb-tools, tor -Description: Whonix configuration for SecureDrop. - This package configures Whonix/Tor for SecureDrop. - Package: securedrop-workstation-config Architecture: all Depends: python3-qubesdb, rsyslog, mailcap, apparmor, nautilus, securedrop-keyring, xfce4-terminal diff --git a/debian/rules b/debian/rules index b66a8d3c60..c561ec5741 100755 --- a/debian/rules +++ b/debian/rules @@ -39,5 +39,6 @@ override_dh_installdeb: override_dh_installsystemd: dh_installsystemd --name securedrop-log-server dh_installsystemd --name securedrop-logging-disabled - dh_installsystemd --name securedrop-whonix-config + dh_installsystemd --name securedrop-proxy-onion-config + dh_installsystemd --name securedrop-arti dh_installsystemd --name securedrop-mime-handling diff --git a/debian/securedrop-proxy.install b/debian/securedrop-proxy.install old mode 100644 new mode 100755 index 7b6cc73951..c8db0d0b15 --- a/debian/securedrop-proxy.install +++ b/debian/securedrop-proxy.install @@ -1,3 +1,5 @@ +#!/usr/bin/dh-exec --with=install proxy/qubes/securedrop.Proxy etc/qubes-rpc/ target/release/securedrop-proxy usr/bin/ proxy/usr.bin.securedrop-proxy /etc/apparmor.d/ +proxy/configure_onion_service.py => usr/bin/securedrop-configure-onion-service diff --git a/debian/securedrop-proxy.securedrop-arti.service b/debian/securedrop-proxy.securedrop-arti.service new file mode 100644 index 0000000000..78c0faffe7 --- /dev/null +++ b/debian/securedrop-proxy.securedrop-arti.service @@ -0,0 +1,28 @@ +[Unit] +Description=System Tor Service (Arti) for securedrop-proxy +After=network.target +Before=nss-lookup.target +Wants=nss-lookup.target +ConditionPathExists=/var/run/qubes-service/securedrop-arti + +[Service] +Type=simple +ExecStart=/usr/bin/arti proxy +ExecReload=/bin/kill -HUP ${MAINPID} +KillSignal=SIGINT +User=_arti +Group=_arti +LimitNOFILE=16384 + +# Create /var/lib/arti +StateDirectory=arti +StateDirectoryMode=0700 + +# Hardening +NoNewPrivileges=yes +PrivateTmp=yes +PrivateDevices=yes +CapabilityBoundingSet=CAP_NET_BIND_SERVICE + +[Install] +WantedBy=multi-user.target diff --git a/debian/securedrop-proxy.securedrop-proxy-onion-config.service b/debian/securedrop-proxy.securedrop-proxy-onion-config.service new file mode 100644 index 0000000000..9d146eaa9f --- /dev/null +++ b/debian/securedrop-proxy.securedrop-proxy-onion-config.service @@ -0,0 +1,15 @@ +[Unit] +Description=SecureDrop Proxy configuration +ConditionPathExists=/var/run/qubes-service/securedrop-arti + +# Ensure that tor is ready +After=securedrop-arti.service + +[Service] +Type=exec +User=_arti +ExecStart=/usr/bin/securedrop-configure-onion-service +RemainAfterExit=yes + +[Install] +WantedBy=multi-user.target diff --git a/debian/securedrop-whonix-config.install b/debian/securedrop-whonix-config.install deleted file mode 100644 index f1c53201da..0000000000 --- a/debian/securedrop-whonix-config.install +++ /dev/null @@ -1 +0,0 @@ -whonix-config/app_journalist.auth_private.tmpl /usr/share/securedrop-whonix-config diff --git a/debian/securedrop-whonix-config.lintian-overrides b/debian/securedrop-whonix-config.lintian-overrides deleted file mode 100644 index 92eb9ae299..0000000000 --- a/debian/securedrop-whonix-config.lintian-overrides +++ /dev/null @@ -1,2 +0,0 @@ -# We don't care -securedrop-whonix-config: package-has-long-file-name diff --git a/debian/securedrop-whonix-config.securedrop-whonix-config.service b/debian/securedrop-whonix-config.securedrop-whonix-config.service deleted file mode 100644 index 993ccb8641..0000000000 --- a/debian/securedrop-whonix-config.securedrop-whonix-config.service +++ /dev/null @@ -1,24 +0,0 @@ -[Unit] -Description=SecureDrop Whonix configuration -ConditionPathExists=/var/run/qubes-service/securedrop-whonix-config - -# Both Qubes's qubes-qrexec-agent (for QubesDB) and Whonix's -# anon-gw-anonymizer-config (for configuration directories) must -# have started *before* this service for it to run successfully, -# since it's a one-shot operation rather than a long-lived service. -Requires=anon-gw-anonymizer-config.service -After=anon-gw-anonymizer-config.service -Requires=qubes-qrexec-agent.service -After=qubes-qrexec-agent.service - -Before=tor.service - -[Service] -Type=oneshot -User=root -ExecStart=/usr/bin/template-from-qubesdb /usr/share/securedrop-whonix-config/app_journalist.auth_private.tmpl /var/lib/tor/authdir/app-journalist.auth_private -ExecStartPost=bash -c "chown debian-tor:debian-tor /var/lib/tor/authdir/app-journalist.auth_private && chmod 0600 /var/lib/tor/authdir/app-journalist.auth_private" -RemainAfterExit=yes - -[Install] -WantedBy=multi-user.target diff --git a/debian/securedrop-workstation-config.lintian-overrides b/debian/securedrop-workstation-config.lintian-overrides index fc99a032d4..7982f41d1a 100644 --- a/debian/securedrop-workstation-config.lintian-overrides +++ b/debian/securedrop-workstation-config.lintian-overrides @@ -8,7 +8,7 @@ securedrop-workstation-config: section-is-dh_make-template securedrop-workstation-config: extended-description-line-too-long # We're just restarting paxctld, it's fine -securedrop-workstation-config: maintainer-script-calls-systemctl [postinst:28] +securedrop-workstation-config: maintainer-script-calls-systemctl # We're not shipping CDs, so this is fine securedrop-workstation-config: package-has-long-file-name diff --git a/debian/securedrop-workstation-config.postinst b/debian/securedrop-workstation-config.postinst index fa453d32e4..20d092a94d 100644 --- a/debian/securedrop-workstation-config.postinst +++ b/debian/securedrop-workstation-config.postinst @@ -20,16 +20,11 @@ set -e case "$1" in configure) - # move pax flags and restart paxctld service - # copy and set default mimeapps handling - # except for whonix-based VMs - if [ ! -e "/etc/whonix_version" ]; then - cp /opt/sdw/paxctld.conf /etc/paxctld.conf - systemctl restart paxctld - cp /opt/sdw/open-in-dvm.desktop /usr/share/applications/ - cp /opt/sdw/mimeapps.list.sd-app /usr/share/applications/mimeapps.list - cp /opt/sdw/mimeapps.list.sd-app /opt/sdw/mimeapps.list.default - fi + cp /opt/sdw/paxctld.conf /etc/paxctld.conf + systemctl restart paxctld + cp /opt/sdw/open-in-dvm.desktop /usr/share/applications/ + cp /opt/sdw/mimeapps.list.sd-app /usr/share/applications/mimeapps.list + cp /opt/sdw/mimeapps.list.sd-app /opt/sdw/mimeapps.list.default ;; abort-upgrade|abort-remove|abort-deconfigure) diff --git a/proxy/Cargo.toml b/proxy/Cargo.toml index eb30af9688..e2263e94f3 100644 --- a/proxy/Cargo.toml +++ b/proxy/Cargo.toml @@ -11,7 +11,7 @@ qubesdb = [] [dependencies] anyhow = {version = "1.0.75"} futures-util = "0.3.30" -reqwest = { version = "0.12", features = ["gzip", "stream"] } +reqwest = { version = "0.12", features = ["gzip", "stream", "socks"] } serde = {version = "1.0.188", features = ["derive"]} serde_json = "1.0.107" tokio = {version = "1.0", features = ["macros", "rt"]} diff --git a/proxy/README.md b/proxy/README.md index 73a01e6d29..716eb87530 100644 --- a/proxy/README.md +++ b/proxy/README.md @@ -40,18 +40,15 @@ sequenceDiagram participant c as securedrop-client participant sdk as securedrop-sdk participant p as securedrop-proxy -participant w as sd-whonix participant server as SecureDrop c ->> sdk: job activate sdk sdk -->> p: JSON over qrexec activate p -p -->> w: HTTP -w -->> server: HTTP over Tor +p -->> server: HTTP over Tor -server -->> w: HTTP over Tor -w -->> p: HTTP +server -->> p: HTTP over Tor alt stream: false p -->> sdk: JSON over qrexec diff --git a/proxy/configure_onion_service.py b/proxy/configure_onion_service.py new file mode 100755 index 0000000000..fc97e2ba85 --- /dev/null +++ b/proxy/configure_onion_service.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 + +import base64 +import os +import struct +import sys +from pathlib import Path + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import x25519 + +try: + from qubesdb import QubesDB +except Exception: + if os.environ.get("PYTEST_VERSION") is not None: + # Running under pytest, we'll mock out QubesDB + QubesDB = None + else: + # Missing and needed, fail + raise + +ARTI_KEYSTORE_PATH = Path("/var/lib/arti/.local/share/arti/keystore") +ARTI_KEY_FILENAME = "ks_hsc_desc_enc.x25519_private" + +OPEN_SSH_ARMOR_TEMPLATE = ( + "-----BEGIN OPENSSH PRIVATE KEY-----\n{key_data}\n-----END OPENSSH PRIVATE KEY-----" +) + + +def get_env(args): + env = {} + try: + db = QubesDB() + for key in args or []: + value = db.read(f"/vm-config/{key}") + if not value or len(value) == 0: + raise KeyError(f"Could not read from QubesDB: {key}") + env[key] = value.decode() + + finally: + db.close() + + return env + + +def convert_onion_client_key(hidserv_key): + """ + ctor -> arti onion service discovery key conversion + + :param hidserv_key: base32-encoded secret from ctor client auth. file + + :returns: converted key + """ + + COMMENT = "" # optional comment stored inside the key + + v = hidserv_key.strip().upper() + v += "=" * ((8 - (len(v) % 8)) % 8) # missing padding + seed = base64.b32decode(v, casefold=True) + + # Clamp per RFC7748 ยง5 (X25519) + k = bytearray(seed) + k[0] &= 248 + k[31] &= 127 + k[31] |= 64 + k = bytes(k) + + # Derive public key + try: + pub = ( + x25519.X25519PrivateKey.from_private_bytes(k) + .public_key() + .public_bytes( + encoding=serialization.Encoding.Raw, format=serialization.PublicFormat.Raw + ) + ) + except Exception as e: + raise SystemExit(f"Error importing key: {e}") + + ALG = b"x25519@spec.torproject.org" + + def ssh_string(b: bytes) -> bytes: + return struct.pack(">I", len(b)) + b + + # Build the outer OpenSSH header + magic = b"openssh-key-v1\0" + ciphername = b"none" + kdfname = b"none" + kdfopts = b"" + nkeys = 1 + + outer = [ + magic, + ssh_string(ciphername), + ssh_string(kdfname), + ssh_string(kdfopts), + struct.pack(">I", nkeys), + ] + + # Public key section (one key): string( keyblob ), + # where keyblob = string(name) + string(pubdata) + # where pubdata is a string-wrapped 32 bytes + pubkey_blob = ssh_string(ALG) + ssh_string(pub) + outer.append(ssh_string(pubkey_blob)) + + # Private section (unencrypted blob) + check = os.urandom(4) + priv_payload = bytearray() + priv_payload += check + check # checkint1, checkint2 + + # One key: + priv_payload += ssh_string(ALG) + priv_payload += ssh_string(pub) # public key data (string-wrapped 32 bytes) + priv_payload += ssh_string(k) # private key data (string-wrapped 32-byte scalar) + priv_payload += ssh_string(COMMENT.encode()) + + # Padding: 1..n so total is multiple of 8 bytes + block = 8 + pad_len = (-len(priv_payload)) % block + if pad_len: + priv_payload += bytes(i % 256 for i in range(1, pad_len + 1)) + + outer.append(ssh_string(bytes(priv_payload))) + key_blob = b"".join(outer) + + # Write PEM-like OpenSSH key + b64 = base64.b64encode(key_blob).decode() + lines = [b64[i : i + 70] for i in range(0, len(b64), 70)] + return OPEN_SSH_ARMOR_TEMPLATE.format(key_data="\n".join(lines)) + + +def setup_journalist_access(keystore_path: Path, hidserv_key, hidserv_hostname): + onion_service_id = ( + hidserv_hostname.removeprefix("http://").removeprefix("https://").removesuffix(".onion") + ) + + onion_service_key_dir = keystore_path / "client" / onion_service_id + onion_service_key_path = onion_service_key_dir / ARTI_KEY_FILENAME + umask_original = os.umask(0o077) # This makes new dirs 700 (777 - 077 = 700) + onion_service_key_dir.mkdir(mode=0o700, exist_ok=True, parents=True) + os.umask(umask_original) + onion_service_key_path.touch(mode=0o600, exist_ok=True) + + with onion_service_key_path.open("w") as key_file: + onion_service_key = convert_onion_client_key(hidserv_key) + key_file.write(onion_service_key) + + +def main(): + # Obtain credentials + env = get_env(["SD_PROXY_ORIGIN", "SD_PROXY_ORIGIN_KEY"]) + hidserv_key = env["SD_PROXY_ORIGIN_KEY"] + hidserv_hostname = env["SD_PROXY_ORIGIN"] + + # Authenticate + setup_journalist_access(ARTI_KEYSTORE_PATH, hidserv_key, hidserv_hostname) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/proxy/poetry.lock b/proxy/poetry.lock index a505020970..ec8e7da10f 100644 --- a/proxy/poetry.lock +++ b/proxy/poetry.lock @@ -78,7 +78,7 @@ version = "1.16.0" description = "Foreign Function Interface for Python calling C code." optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, @@ -133,6 +133,7 @@ files = [ {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"}, {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, ] +markers = {main = "platform_python_implementation != \"PyPy\""} [package.dependencies] pycparser = "*" @@ -165,6 +166,66 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "cryptography" +version = "45.0.6" +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." +optional = false +python-versions = "!=3.9.0,!=3.9.1,>=3.7" +groups = ["main"] +files = [ + {file = "cryptography-45.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:048e7ad9e08cf4c0ab07ff7f36cc3115924e22e2266e034450a890d9e312dd74"}, + {file = "cryptography-45.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44647c5d796f5fc042bbc6d61307d04bf29bccb74d188f18051b635f20a9c75f"}, + {file = "cryptography-45.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e40b80ecf35ec265c452eea0ba94c9587ca763e739b8e559c128d23bff7ebbbf"}, + {file = "cryptography-45.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:00e8724bdad672d75e6f069b27970883179bd472cd24a63f6e620ca7e41cc0c5"}, + {file = "cryptography-45.0.6-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a3085d1b319d35296176af31c90338eeb2ddac8104661df79f80e1d9787b8b2"}, + {file = "cryptography-45.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1b7fa6a1c1188c7ee32e47590d16a5a0646270921f8020efc9a511648e1b2e08"}, + {file = "cryptography-45.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:275ba5cc0d9e320cd70f8e7b96d9e59903c815ca579ab96c1e37278d231fc402"}, + {file = "cryptography-45.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4028f29a9f38a2025abedb2e409973709c660d44319c61762202206ed577c42"}, + {file = "cryptography-45.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ee411a1b977f40bd075392c80c10b58025ee5c6b47a822a33c1198598a7a5f05"}, + {file = "cryptography-45.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2a21a8eda2d86bb604934b6b37691585bd095c1f788530c1fcefc53a82b3453"}, + {file = "cryptography-45.0.6-cp311-abi3-win32.whl", hash = "sha256:d063341378d7ee9c91f9d23b431a3502fc8bfacd54ef0a27baa72a0843b29159"}, + {file = "cryptography-45.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:833dc32dfc1e39b7376a87b9a6a4288a10aae234631268486558920029b086ec"}, + {file = "cryptography-45.0.6-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:3436128a60a5e5490603ab2adbabc8763613f638513ffa7d311c900a8349a2a0"}, + {file = "cryptography-45.0.6-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d9ef57b6768d9fa58e92f4947cea96ade1233c0e236db22ba44748ffedca394"}, + {file = "cryptography-45.0.6-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea3c42f2016a5bbf71825537c2ad753f2870191134933196bee408aac397b3d9"}, + {file = "cryptography-45.0.6-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:20ae4906a13716139d6d762ceb3e0e7e110f7955f3bc3876e3a07f5daadec5f3"}, + {file = "cryptography-45.0.6-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dac5ec199038b8e131365e2324c03d20e97fe214af051d20c49db129844e8b3"}, + {file = "cryptography-45.0.6-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:18f878a34b90d688982e43f4b700408b478102dd58b3e39de21b5ebf6509c301"}, + {file = "cryptography-45.0.6-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5bd6020c80c5b2b2242d6c48487d7b85700f5e0038e67b29d706f98440d66eb5"}, + {file = "cryptography-45.0.6-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:eccddbd986e43014263eda489abbddfbc287af5cddfd690477993dbb31e31016"}, + {file = "cryptography-45.0.6-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:550ae02148206beb722cfe4ef0933f9352bab26b087af00e48fdfb9ade35c5b3"}, + {file = "cryptography-45.0.6-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b64e668fc3528e77efa51ca70fadcd6610e8ab231e3e06ae2bab3b31c2b8ed9"}, + {file = "cryptography-45.0.6-cp37-abi3-win32.whl", hash = "sha256:780c40fb751c7d2b0c6786ceee6b6f871e86e8718a8ff4bc35073ac353c7cd02"}, + {file = "cryptography-45.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b"}, + {file = "cryptography-45.0.6-pp310-pypy310_pp73-macosx_10_9_x86_64.whl", hash = "sha256:705bb7c7ecc3d79a50f236adda12ca331c8e7ecfbea51edd931ce5a7a7c4f012"}, + {file = "cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:826b46dae41a1155a0c0e66fafba43d0ede1dc16570b95e40c4d83bfcf0a451d"}, + {file = "cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:cc4d66f5dc4dc37b89cfef1bd5044387f7a1f6f0abb490815628501909332d5d"}, + {file = "cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f68f833a9d445cc49f01097d95c83a850795921b3f7cc6488731e69bde3288da"}, + {file = "cryptography-45.0.6-pp310-pypy310_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3b5bf5267e98661b9b888a9250d05b063220dfa917a8203744454573c7eb79db"}, + {file = "cryptography-45.0.6-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:2384f2ab18d9be88a6e4f8972923405e2dbb8d3e16c6b43f15ca491d7831bd18"}, + {file = "cryptography-45.0.6-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fc022c1fa5acff6def2fc6d7819bbbd31ccddfe67d075331a65d9cfb28a20983"}, + {file = "cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3de77e4df42ac8d4e4d6cdb342d989803ad37707cf8f3fbf7b088c9cbdd46427"}, + {file = "cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:599c8d7df950aa68baa7e98f7b73f4f414c9f02d0e8104a30c0182a07732638b"}, + {file = "cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:31a2b9a10530a1cb04ffd6aa1cd4d3be9ed49f7d77a4dafe198f3b382f41545c"}, + {file = "cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:e5b3dda1b00fb41da3af4c5ef3f922a200e33ee5ba0f0bc9ecf0b0c173958385"}, + {file = "cryptography-45.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:629127cfdcdc6806dfe234734d7cb8ac54edaf572148274fa377a7d3405b0043"}, + {file = "cryptography-45.0.6.tar.gz", hash = "sha256:5c966c732cf6e4a276ce83b6e4c729edda2df6929083a952cc7da973c539c719"}, +] + +[package.dependencies] +cffi = {version = ">=1.14", markers = "platform_python_implementation != \"PyPy\""} + +[package.extras] +docs = ["sphinx (>=5.3.0)", "sphinx-inline-tabs ; python_full_version >= \"3.8.0\"", "sphinx-rtd-theme (>=3.0.0) ; python_full_version >= \"3.8.0\""] +docstest = ["pyenchant (>=3)", "readme-renderer (>=30.0)", "sphinxcontrib-spelling (>=7.3.1)"] +nox = ["nox (>=2024.4.15)", "nox[uv] (>=2024.3.2) ; python_full_version >= \"3.8.0\""] +pep8test = ["check-sdist ; python_full_version >= \"3.8.0\"", "click (>=8.0.1)", "mypy (>=1.4)", "ruff (>=0.3.6)"] +sdist = ["build (>=1.0.0)"] +ssh = ["bcrypt (>=3.1.5)"] +test = ["certifi (>=2024)", "cryptography-vectors (==45.0.6)", "pretend (>=0.7)", "pytest (>=7.4.0)", "pytest-benchmark (>=4.0)", "pytest-cov (>=2.10.1)", "pytest-xdist (>=3.5.0)"] +test-randomorder = ["pytest-randomly"] + [[package]] name = "decorator" version = "5.1.1" @@ -594,11 +655,12 @@ version = "2.22" description = "C parser in Python" optional = false python-versions = ">=3.8" -groups = ["dev"] +groups = ["main", "dev"] files = [ {file = "pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"}, {file = "pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6"}, ] +markers = {main = "platform_python_implementation != \"PyPy\""} [[package]] name = "pytest" @@ -639,6 +701,24 @@ httpbin = "*" [package.extras] test = ["pytest", "requests"] +[[package]] +name = "pytest-mock" +version = "3.14.1" +description = "Thin-wrapper around the mock package for easier use with pytest" +optional = false +python-versions = ">=3.8" +groups = ["dev"] +files = [ + {file = "pytest_mock-3.14.1-py3-none-any.whl", hash = "sha256:178aefcd11307d874b4cd3100344e7e2d888d9791a6a1d9bfe90fbc1b74fd1d0"}, + {file = "pytest_mock-3.14.1.tar.gz", hash = "sha256:159e9edac4c451ce77a5cdb9fc5d1100708d2dd4ba3c3df572f14097351af80e"}, +] + +[package.dependencies] +pytest = ">=6.2.5" + +[package.extras] +dev = ["pre-commit", "pytest-asyncio", "tox"] + [[package]] name = "pyyaml" version = "6.0.1" @@ -858,4 +938,4 @@ watchdog = ["watchdog (>=2.3)"] [metadata] lock-version = "2.1" python-versions = ">=3.11" -content-hash = "ca08dfa2531208315a1ef6d71bc22f1f4c02d065e79d0d6c3e281477c702300b" +content-hash = "0f731a0e66ee7613ed10be3eeda662f7e80d5d7b17e5e0fb5b7953cc39ec4905" diff --git a/proxy/pyproject.toml b/proxy/pyproject.toml index 31e1149d74..a299e628c4 100644 --- a/proxy/pyproject.toml +++ b/proxy/pyproject.toml @@ -8,7 +8,7 @@ authors = [ ] license = {text = "AGPLv3+"} readme = "README.md" -dependencies = [] +dependencies = ["cryptography (>=45.0.6,<46.0.0)"] [tool.poetry] requires-poetry = ">=2.1.0,<3.0.0" @@ -17,3 +17,4 @@ package-mode = false [tool.poetry.group.dev.dependencies] pytest = "^8.3.5" pytest-httpbin = "^2.1.0" +pytest-mock = "^3.14.1" diff --git a/proxy/src/main.rs b/proxy/src/main.rs index b6e04772ea..cbae10d515 100644 --- a/proxy/src/main.rs +++ b/proxy/src/main.rs @@ -3,8 +3,7 @@ use anyhow::{bail, Result}; use futures_util::StreamExt; use reqwest::header::HeaderMap; -use reqwest::Method; -use reqwest::{Client, Response}; +use reqwest::{Client, Method, Proxy, Response}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::io; @@ -27,6 +26,7 @@ use config_env as config; // This is the only setting we need to read via `config`. We should refactor this more extensibly if we ever need multiple. const ENV_CONFIG: &str = "SD_PROXY_ORIGIN"; +const DISABLE_TOR: &str = "DISABLE_TOR"; /// Incoming HTTP requests (as JSON) received over stdin #[derive(Deserialize, Debug)] @@ -114,6 +114,7 @@ async fn handle_stream_response(resp: Response) -> Result<()> { async fn proxy() -> Result<()> { // Get the hostname from the environment or QubesDB let origin = config::read(ENV_CONFIG)?; + // Read incoming request from stdin (must be on single line) let mut buffer = String::new(); io::stdin().read_line(&mut buffer)?; @@ -134,7 +135,16 @@ async fn proxy() -> Result<()> { bail! {"request would escape configured origin"} } - let client = Client::new(); + // Ability to disable tor explicitly (for dev/testing purposes) + let client = if config::read(DISABLE_TOR).is_ok() { + Client::new() + } else { + Client::builder() + // the *h* in socks5h has the proxy (i.e. Tor) resolve DNS (*h*ostnames) + .proxy(Proxy::http("socks5h://127.0.0.1:9150")?) + .build()? + }; + let mut req = client.request(Method::from_str(&incoming_request.method)?, url); let header_map = HeaderMap::try_from(&incoming_request.headers)?; diff --git a/proxy/tests/conftest.py b/proxy/tests/conftest.py index 7a5f732558..d55ddfe52e 100644 --- a/proxy/tests/conftest.py +++ b/proxy/tests/conftest.py @@ -3,9 +3,15 @@ import json import os import subprocess +import sys +from pathlib import Path import pytest +# HACK Add the parent directory to the sys.path +# (bypass need for proper python project structure) +sys.path.append(str(Path(__file__).resolve().parent.parent)) + @pytest.fixture(scope="session") def proxy_bin() -> str: @@ -19,14 +25,21 @@ def proxy_bin() -> str: @pytest.fixture def proxy_request(httpbin, proxy_bin): - def proxy_(input: bytes | dict, origin: str | None = None) -> subprocess.CompletedProcess: + def proxy_( + input: bytes | dict, origin: str | None = None, use_tor=False + ) -> subprocess.CompletedProcess: if isinstance(input, dict): input = json.dumps(input).encode() if origin is None: origin = httpbin.url + + env = {"SD_PROXY_ORIGIN": origin} + if not use_tor: + env["DISABLE_TOR"] = "yes" + return subprocess.run( [proxy_bin], - env={"SD_PROXY_ORIGIN": origin}, + env=env, input=input, capture_output=True, check=False, diff --git a/proxy/tests/test_configure_onion_service.py b/proxy/tests/test_configure_onion_service.py new file mode 100644 index 0000000000..470dffae1e --- /dev/null +++ b/proxy/tests/test_configure_onion_service.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +import os +from unittest.mock import patch + +import pytest +from configure_onion_service import ( + ARTI_KEY_FILENAME, + convert_onion_client_key, + get_env, + setup_journalist_access, +) + +# File stat bit mask, used to obtain file permissions +PERM_BITMASK = 0o777 + + +@pytest.fixture +def qubesdb_mock(mocker): + qubesdb_mock = mocker.MagicMock() + qubesdb_mock.read.side_effect = "42" + mocker.patch("configure_onion_service.QubesDB", side_effect=qubesdb_mock) + return qubesdb_mock.return_value + + +@pytest.mark.parametrize( + ("keys", "expected_env"), + [(["key"], {"key": "value"}), (["key1", "key2"], {"key1": "value1", "key2": "value2"})], +) +def test_get_env(keys, expected_env, qubesdb_mock): + qubesdb_mock.read.side_effect = [v.encode() for v in expected_env.values()] + assert expected_env == get_env(keys) + + +@pytest.mark.parametrize( + ("ctor_key", "arti_key"), + [ + ( + "DKBZHBQUBTVSHCO62IEBOUTXFUA7TVTFBSH2Y7CQREMVWUZ72ZKQ", + """-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAQgAAABp4MjU1MT +lAc3BlYy50b3Jwcm9qZWN0Lm9yZwAAACCH0gGiwMNLM9nXuS5Y3jjyC3jQKYufpk2ij4Uc +AUnnfAAAAHgAAQIDAAECAwAAABp4MjU1MTlAc3BlYy50b3Jwcm9qZWN0Lm9yZwAAACCH0g +GiwMNLM9nXuS5Y3jjyC3jQKYufpk2ij4UcAUnnfAAAACAYg5OGFAzrI4ne0ggXUnctAfnW +ZQyPrHxQiRlbUz/WVQAAAAABAgMEBQY= +-----END OPENSSH PRIVATE KEY-----""", + ), + ( + "4CLLIHTNS7KMOYQIE6XKZ2TDI2S7CYAQAHMFJRQPMHPPEUGX5FEQ", + """-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAQgAAABp4MjU1MT +lAc3BlYy50b3Jwcm9qZWN0Lm9yZwAAACCt3L/s3fLVYTR61oYR3dpiatITcRaLmKDyqeTO +MclEMwAAAHgAAQIDAAECAwAAABp4MjU1MTlAc3BlYy50b3Jwcm9qZWN0Lm9yZwAAACCt3L +/s3fLVYTR61oYR3dpiatITcRaLmKDyqeTOMclEMwAAACDglrQebZfUx2IIJ66s6mNGpfFg +EAHYVMYPYd7yUNfpSQAAAAABAgMEBQY= +-----END OPENSSH PRIVATE KEY-----""", + ), + ( + "TMNSKMJYP7FYYDJNRT5ATGRMWUG34DNQOXVTEVCDVCIIRV66RUCA", + """-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAQgAAABp4MjU1MT +lAc3BlYy50b3Jwcm9qZWN0Lm9yZwAAACALhKtBxmb9cmMqaaK82zmvbt8xedPeSR8xdDay +5f1NWwAAAHgAAQIDAAECAwAAABp4MjU1MTlAc3BlYy50b3Jwcm9qZWN0Lm9yZwAAACALhK +tBxmb9cmMqaaK82zmvbt8xedPeSR8xdDay5f1NWwAAACCYGyUxOH/LjA0tjPoJmiy1Db4N +sHXrMlRDqJCI196NRAAAAAABAgMEBQY= +-----END OPENSSH PRIVATE KEY-----""", + ), + ], +) +@patch( # Mock os.urandom to return a specific value when called with 4 + "os.urandom", side_effect=lambda n: b"\x00\x01\x02\x03" if n == 4 else os.urandom(n) +) +def test_convert_onion_client_key(urandom_patch, ctor_key, arti_key): + assert arti_key == convert_onion_client_key(ctor_key) + + +@patch("configure_onion_service.convert_onion_client_key", return_value="MOCKED_KEY") +def test_setup_journalist_access(mocker, tmp_path): + hidserv_key = "TMNSKMJYP7FYYDJNRT5ATGRMWUG34DNQOXVTEVCDVCIIRV66RUCA" + hidserv_id = "SOME_HOSTNAME_WITHOUT_DOT_ONION" + hidserv_hostname = f"http://{hidserv_id}.onion" + setup_journalist_access(tmp_path, hidserv_key, hidserv_hostname) + + key_dir = tmp_path / "client" / hidserv_id + key_path = key_dir / ARTI_KEY_FILENAME + # Folder isn't world-readable + assert os.stat(key_dir).st_mode & PERM_BITMASK == 0o700 + # And the umask controlled the parent directory permissions + assert os.stat(key_dir.parent).st_mode & PERM_BITMASK == 0o700 + # Same with the keyfile + assert os.stat(key_path).st_mode & PERM_BITMASK == 0o600 + + assert key_path.read_text() == "MOCKED_KEY" diff --git a/supply-chain/audits.toml b/supply-chain/audits.toml index 0cdb8dd572..030bedfe2a 100644 --- a/supply-chain/audits.toml +++ b/supply-chain/audits.toml @@ -112,6 +112,12 @@ who = "Kunal Mehta " criteria = "safe-to-run" delta = "3.9.0 -> 3.10.0" +[[audits.tokio-socks]] +who = "Kunal Mehta " +criteria = "safe-to-run" +version = "0.5.2" +notes = "reasonably clean, no unsafe code" + [[audits.tower-layer]] who = "Kunal Mehta " criteria = "safe-to-run" @@ -439,6 +445,20 @@ start = "2019-03-01" end = "2024-08-12" notes = "Rust Project member" +[[trusted.thiserror]] +criteria = "safe-to-deploy" +user-id = 3618 # David Tolnay (dtolnay) +start = "2019-10-09" +end = "2026-02-25" +notes = "Rust Project member" + +[[trusted.thiserror-impl]] +criteria = "safe-to-deploy" +user-id = 3618 # David Tolnay (dtolnay) +start = "2019-10-09" +end = "2026-02-25" +notes = "Rust Project member" + [[trusted.tokio]] criteria = "safe-to-deploy" user-id = 10 # Carl Lerche (carllerche) diff --git a/supply-chain/imports.lock b/supply-chain/imports.lock index 44c7bdd29b..d43a4509a5 100644 --- a/supply-chain/imports.lock +++ b/supply-chain/imports.lock @@ -253,6 +253,20 @@ user-id = 3618 user-login = "dtolnay" user-name = "David Tolnay" +[[publisher.thiserror]] +version = "1.0.65" +when = "2024-10-22" +user-id = 3618 +user-login = "dtolnay" +user-name = "David Tolnay" + +[[publisher.thiserror-impl]] +version = "1.0.65" +when = "2024-10-22" +user-id = 3618 +user-login = "dtolnay" +user-name = "David Tolnay" + [[publisher.tokio]] version = "1.44.2" when = "2025-04-05" diff --git a/whonix-config/app_journalist.auth_private.tmpl b/whonix-config/app_journalist.auth_private.tmpl deleted file mode 100644 index c4fa18661c..0000000000 --- a/whonix-config/app_journalist.auth_private.tmpl +++ /dev/null @@ -1 +0,0 @@ -${SD_HIDSERV_HOSTNAME}:descriptor:x25519:${SD_HIDSERV_KEY} \ No newline at end of file