pqconnect-1.2.1/0000755000000000000000000000000014733452565012221 5ustar rootrootpqconnect-1.2.1/Dockerfile0000644000000000000000000000111214733452565014206 0ustar rootrootFROM debian:bullseye AS build USER root RUN apt-get update && \ apt-get install -y build-essential \ clang wget python3 python3-pip \ python3-venv sudo libssl-dev \ libsodium-dev git valgrind RUN pip install build RUN useradd --create-home pqconnect RUN mkdir /home/pqconnect/python && \ mkdir /home/pqconnect/keys/ WORKDIR /home/pqconnect/python ADD Makefile . ADD scripts . ADD pyproject.toml . ADD README.md . ADD LICENSE . ADD server.patch . ADD src . RUN patch pyproject.toml server.patch RUN make install RUN ldconfig RUN pqconnect-keygen CMD ["pqconnect-server"] pqconnect-1.2.1/LICENSE0000644000000000000000000000053214733452565013226 0ustar rootrootThis package is hereby placed into the public domain. [SPDX-License-Identifier](https://spdx.dev/ids/): [LicenseRef-PD-hp](https://cr.yp.to/spdx.html) OR [CC0-1.0](https://spdx.org/licenses/CC0-1.0.html) OR [0BSD](https://spdx.org/licenses/0BSD.html) OR [MIT-0](https://spdx.org/licenses/MIT-0.html) OR [MIT](https://spdx.org/licenses/MIT.html) pqconnect-1.2.1/Makefile0000644000000000000000000000360714733452565013667 0ustar rootrootUSER:=pqconnect GROUP:=pqconnect VENV := venv PYTHON := $(VENV)/bin/python3 PIPFLAGS := $(shell python3 scripts/external.py) default: build .PHONY: test clean venv/bin/activate: pyproject.toml python3 -m venv $(VENV) $(PYTHON) -m pip install .[dev] setup: venv/bin/activate format: venv/bin/activate -$(PYTHON) -m isort src $(PYTHON) -m black src lint: venv/bin/activate -$(PYTHON) -m pyflakes src -$(PYTHON) -m pylint src -$(PYTHON) -m flake8 src -$(PYTHON) -m ruff src $(PYTHON) -m tryceratops src type: venv/bin/activate -$(PYTHON) -m mypy src -$(PYTHON) -m pyright src [ -e .pyre_configuration ] || echo "src" | $(VENV)/bin/pyre init $(VENV)/bin/pyre check coverage: venv/bin/activate sudo $(PYTHON) -m coverage report -m dead: venv/bin/activate -$(PYTHON) -m vulture src build-deps: scripts/download-build-install-deps test: venv/bin/activate build-deps sudo $(PYTHON) -m coverage run -m unittest discover -v test_%.py: venv/bin/activate build-deps sudo $(PYTHON) -m coverage run -m unittest test/$@ build: build-deps python3 -m build ls -alh dist/pqconnect* audit-and-build: venv/bin/activate format lint type dead test build install-user-and-group: -getent passwd ${USER} $2>/dev/null || sudo useradd -g ${GROUP} -r -m -s /bin/false ${USER} -getent group ${GROUP} $2>/dev/null || sudo groupadd -r ${GROUP} install: build install-user-and-group -sudo pip install $(PIPFLAGS) dist/pqconnect-0.0.1-py3-none-any.whl install-systemd-unitfiles: sudo cp misc/pqconnect-client.service /etc/systemd/system/ sudo chmod 644 /etc/systemd/system/pqconnect-client.service sudo systemctl daemon-reload clean: rm -rf dist downloads venv .pyre .pyre_configuration .coverage .ruff_cache .mypy_cache uninstall: clean -sudo pip uninstall $(PIPFLAGS) pqconnect test-run: @echo "Start pqconnect in another terminal and press [Enter]:" @bash -c read -n1 curl -L www.pqconnect.net/test.html pqconnect-1.2.1/README.md0000644000000000000000000000017114733452565013477 0ustar rootrootFull documentation for PQConnect is located in the top-level `doc` directory. A good place to start is `doc/readme.md`. pqconnect-1.2.1/doc/0000755000000000000000000000000014733452565012766 5ustar rootrootpqconnect-1.2.1/doc/compat.md0000644000000000000000000001774014733452565014604 0ustar rootroot## Backward compatibility Preserving connectivity is critical. After you install the PQConnect client software, your machine will connect to PQConnect servers _and_ will continue to connect to non-PQConnect servers. PQConnect is designed so that the PQConnect client software detects PQConnect servers _without_ sending extra queries to non-PQConnect servers. (Such queries might trigger hyperactive firewalls to break connectivity.) Similarly, if you are a sysadmin installing the PQConnect server software, your machine will continue to allow connections from non-PQConnect clients. This compatibility works using CNAME records, a standard DNS feature (for example, `www.amazon.com` relies on CNAME records). To announce PQConnect support for `www.your.server`, you will rename the existing DNS records for `www.your.server` (typically just an A record showing the server's IP address) under a new name determined by PQConnect, and you will set up a DNS CNAME record pointing from `www.your.server` to the new name. For example, `www.pqconnect.net` has a CNAME record pointing to `pq1u1hy1ujsuk258krx3ku6wd9rp96kfxm64mgct3s3j26udp57dbu1.pqconnect.net`, which in turn has an A record listing the server's IP address. Non-PQConnect clients follow the CNAME record and connect to the server. PQConnect clients recognize the CNAME record as a PQConnect announcement and make an encrypted connection to the server. ## Forward compatibility PQConnect announcements include a version number `pq1`. This supports smooth future upgrades in which clients are upgraded to allow a modified `pq2` protocol, and then servers can freely begin announcing `pq2`. ## Subdomains PQConnect is not limited to `www.your.server`. You can also announce PQConnect support for `imap.your.server`, `zulip.your.server`, or whatever other subdomains you want within your DNS domains. However, you cannot set up a DNS CNAME record specifically for the second-level name `your.server` delegated from the top-level `.server` administrators. DNS does not allow CNAME records to have exactly the same name as other records, such as delegation records. It would be possible for PQConnect to work around this restriction by inserting PQConnect announcements into delegation records, but currently PQConnect focuses on protecting subdomains. ## Operating systems The initial PQConnect software release is for Linux. The software installation relies on packages supplied by Linux distributions. Package names are not synchronized across Linux distributions. The installation currently understands the names for Debian; Debian derivatives such as Ubuntu and Raspbian; Arch; and Gentoo. Adding further distributions should be easy. Support for non-Linux operating systems is planned, handling the different mechanisms that different operating systems provide for reading and writing IP-layer packets. The PQConnect system as a whole is designed to be compatible with any operating system. The PQConnect software is written in Python. The underlying C libraries for cryptography have already been ported to MacOS. Accessing the IP layer is not the only way to implement the PQConnect protocol. Existing user-level applications access the kernel's network stack via system calls, normally via `libc`. It is possible to modify those network packets by modifying the kernel, by modifying `libc`, or by pre-loading a PQConnect dynamic library, still without touching the individual applications. Also, most applications access DNS at the servers designated in `/etc/resolv.conf`, usually via `libc`, so it is possible to modify DNS packets by changing `libc`, by modifying `/etc/resolv.conf` to point to local DNS software that handles PQConnect, or by modifying existing local DNS software to handle PQConnect (via plugins where applicable, or by code modifications). These software choices can also be of interest to apply PQConnect to applications that manage to dodge the current PQConnect software. ## Applications Our experiments have found the PQConnect software successfully wrapping post-quantum cryptography around a wide range of applications. However, there is no guarantee that PQConnect covers all applications. For example, an application might read a server address from a local file without using DNS queries, might use its own encrypted tunnel to a DNS proxy, or might otherwise deviate from the normal modular usage of DNS services provided by the operating system. These applications do not receive the benefits of PQConnect: they will continue to make non-PQConnect-protected connections as usual. A notable example is Firefox, which automatically uses DNS over HTTPS in some cases to send DNS queries to Cloudflare. A DNS proxy (or DNS packet rewriting) can disable this by creating an IP address for `use-application-dns.net`; this allows Firefox to benefit from PQConnect, and is still compatible with passing DNS queries locally to a modular DNS-over-HTTPS client. A user _manually_ configuring Firefox to use DNS over HTTPS will prevent Firefox from using PQConnect. ## Transport-layer security SSH connections, TLS connections, etc. work smoothly over PQConnect. The software managing those security mechanisms doesn't notice that everything is protected inside a PQConnect tunnel. The PQConnect software doesn't notice that the packets it's encrypting already have another layer of encryption. ## VPNs Conceptually, running the PQConnect protocol on top of a VPN protocol, or vice versa, is a simple matter of routing packets in the desired order through PQConnect and the VPN. So far we haven't written scripts to do this, but if you have specific use cases then please share details in the Compatibility channel on the [PQConnect chat server](index.html#chat). ## Firewalls PQConnect encrypts and authenticates complete IP packets, including port numbers. After decrypting a packet, PQConnect forwards the packet to the local machine on whichever port number is specified by the client. One consequence of this encryption is that you cannot rely on a firewall outside your machine to block ports: any desired port blocking must be handled by a firewall inside your machine. Note that an external firewall also does not block attackers who have compromised a router or network card between the firewall and your computer. You may be behind a firewall that restricts which ports you can use: for example, the firewall may block low ports, or may block high ports. PQConnect is flexible in which ports it uses. The `-p` option for the `pqconnect` program chooses a client port. The `-p` and `-k` options for the `pqconnect-server` program choose a crypto-server port and a key-server port. All of these are UDP ports. ## IP versions Our PQConnect tests have been with IPv4, but the protocol should also work with IPv6. The PQConnect handshake packets are small enough that even multiple levels of surrounding tunnels should stay below the 1500-byte Ethernet limit on packet sizes. ## Application-layer surveillance The PQConnect server software automatically replaces client IP addresses with local addresses such as 10.10.0.5 when it delivers packets to applications running on your server. Hiding client addresses can help protect privacy against applications that are careless in handling client data, and can help comply with privacy regulations. If you need applications to be able to check client locations to route clients to nearby servers for efficiency, one option is to provide different DNS responses to clients in different locations (using, e.g., the "client location" feature in tinydns), already pointing those clients to nearby servers at DNS time rather than having the application perform this routing. If you need to check client information in logs for abuse tracking, one option is to collate PQConnect logs and application logs, still without exposing client IP addresses to the application. pqconnect-1.2.1/doc/crypto.md0000644000000000000000000003575214733452565014644 0ustar rootrootThis page explains PQConnect's top three cryptographic goals, and various aspects of how PQConnect aims to achieve those goals. There is a [separate page](security.html) looking more broadly at security. ## Priority 1: post-quantum encryption Attackers are [carrying out mass surveillance of Internet traffic](https://www.theguardian.com/uk-news/2021/may/25/gchqs-mass-data-sharing-violated-right-to-privacy-court-rules). They are [saving encrypted data to break later](https://www.forbes.com/sites/andygreenberg/2013/06/20/leaked-nsa-doc-says-it-can-collect-and-keep-your-encrypted-data-as-long-as-it-takes-to-crack-it/). They are years ahead of the public in [investing in quantum computers](https://www.washingtonpost.com/world/national-security/nsa-seeks-to-build-quantum-computer-that-could-crack-most-types-of-encryption/2014/01/02/8fff297e-7195-11e3-8def-a33011492df2_story.html). The ciphertexts we send are irrevocably shown to any attackers monitoring the network; we cannot retroactively improve the encryption of that data. The top priority for PQConnect is to switch as much Internet traffic as possible, as quickly as possible, to high-security end-to-end post-quantum encryption. To the extent that some applications have already been rolling out post-quantum encryption, great! PQConnect adds another layer of defense in case that fails, a layer systematically designed for high security. But the more obvious benefit of PQConnect is for applications that are still using pre-quantum encryption or no encryption at all. PQConnect provides a fast application-independent path to post-quantum cryptography. ## Priority 2: post-quantum authentication Another important goal of PQConnect is to switch as much Internet traffic as possible, as quickly as possible, to high-security end-to-end post-quantum _authentication_. The urgency of post-quantum authentication is not as obvious as the urgency of post-quantum encryption. Consider, for example, an application relying on pre-quantum signatures for authentication. Assume that the application is upgraded so that all verifiers accept post-quantum signatures, and then upgraded to replace all generated pre-quantum signatures with post-quantum signatures, and then upgraded so that verifiers stop accepting pre-quantum signatures, with all of these upgrades deployed by all signers and verifiers before the attacker has a quantum computer. There will then be no verifiers accepting the attacker's forged pre-quantum signatures. However, the timeline for upgrades is variable and often extremely slow. For example, within web pages loaded by Firefox, the [percentage using HTTPS](https://letsencrypt.org/stats/) was around 30% in 2014, around 80% in 2020, and still around 80% in 2024. There are clear risks that, when the first public demonstrations of quantum attacks appear, many applications will still be using pre-quantum cryptography, while real quantum attacks will already have been carried out in secret. Starting earlier on upgrades will reduce the damage. ## Priority 3: fast post-quantum key erasure Sometimes a user's device is stolen or otherwise compromised by an attacker. Perhaps this allows attackers to find decryption keys inside the device, and to use those keys to decrypt ciphertexts that the attacker previously recorded. Of course, the big problem here is that secrets stored on a user device were exposed in the first place. What one wants is better protection for all data stored on the device. However, in case that protection fails, the damage may be reduced if keys are preemptively erased. PQConnect sets a goal of having each ciphertext no longer decryptable 2 minutes later, even if the client and server devices are subsequently compromised by an attacker also having a quantum computer. Concretely, PQConnect encrypts each ciphertext using a post-quantum key that is erased by the client and by the server within 2 minutes. This erasure happens _within_ each PQConnect tunnel, no matter how long the tunnel lasts. For comparison, the "ephemeral" options in TLS are often claimed to provide ["Perfect Forward Secrecy"](https://datatracker.ietf.org/doc/html/rfc5246), but these options still allow ciphertexts to be decryptable for [as long as a TLS session lasts](https://www.imperialviolet.org/2013/06/27/botchingpfs.html). A [2016 study](https://jhalderm.com/pub/papers/forward-secrecy-imc16.pdf) found that "connections to 38% of Top Million HTTPS sites are vulnerable to decryption if the server is compromised up to 24 hours later, and 10% up to 30 days later". Current security guides that ask TLS applications to [disable session resumption](https://docs.veracode.com/r/harden-tls-session-resumption) do not prevent sessions from lasting for hours or longer. ## Full-packet encryption PQConnect encrypts the complete packets sent by applications, including protocol headers and port numbers. Attackers may be able to deduce the same information by analyzing metadata such as the timings and lengths of packets, but this is not a reason to simply give the data away. ## VPNs and BPNs VPNs typically share PQConnect's features of being application-independent and encrypting full packets. However, VPNs generally do not provide end-to-end security. A client sets up a VPN to encrypt traffic to a VPN proxy, but then traffic is exposed at the VPN proxy, and at every point between the VPN proxy and the ultimate server. It is possible to manually configure typical VPN software so that a connection to `www.your.server` goes through a VPN tunnel to `www.your.server`, a connection to `www.alices.server` goes through a VPN tunnel to `www.alices.server`, etc., when this is supported by the servers. PQConnect _automates_ the processes of announcing server support and of creating these tunnels. In English, "boring a tunnel" means creating a tunnel by digging, typically with a tool. PQConnect is a "BPN": a "Boring Private Network". The PQConnect mascot is a Taiwanese pangolin. Pangolins dig tunnels and are protected by their armor. The Mandarin name for pangolins is 穿山甲, literally "pierce mountain armor". Legend says that pangolins travel the world through their tunnels. There is another use of the word "boring" in cryptography: ["boring cryptography"](https://cr.yp.to/talks.html#2015.10.05) is cryptography that simply works, solidly resists attacks, and never needs any upgrades. PQConnect also aims to be boring in this sense. ## Double public-key encryption: ECC+PQ To the extent that applications have upgraded to post-quantum public-key encryption, they are normally using it as a second layer on top of pre-quantum public-key encryption (typically X25519), rather than as a replacement for pre-quantum public-key encryption. This [reduces the damage](security.html#non-nocere) in case of a security failure in the post-quantum software: the impact is delayed until the attacker has a quantum computer. PQConnect follows this approach. One difference in details is that PQConnect replaces typical concatenated encryption with nested encryption to reduce attack surface. ## Conservative public-key encryption: McEliece PQConnect does not use the presence of an ECC backup as an excuse for risky PQ choices. A devastating PQ failure would mean that goal #1 is not achieved. The foundation of security in PQConnect is the [Classic McEliece](https://classic.mceliece.org) encryption system at a [very high security level](https://cat.cr.yp.to/cryptattacktester-20240612.pdf#page.28), specifically `mceliece6960119`; the software uses [libmceliece](https://lib.mceliece.org). Among proposals for post-quantum public-key encryption, the McEliece cryptosystem is unique in how strong its security track record is: more than [50 papers](https://isd.mceliece.org) attacking the system since 1978 have produced [only tiny changes in the McEliece security level](https://cr.yp.to/talks/2024.09.17/slides-djb-20240917-mceliece-16x9.pdf#page.16). Classic McEliece is also used in the [Mullvad](https://mullvad.net/en/blog/stable-quantum-resistant-tunnels-in-the-app) and [Rosenpass](https://rosenpass.eu/) VPNs, and in various [other applications](https://mceliece.org). Each PQConnect server has a long-term 1MB Classic McEliece key that it sends out upon request. To prevent amplification, PQConnect pads the request to 1MB. This cost is only per-client, not per-tunnel or per-connection. The PQConnect client software generates and saves many Classic McEliece ciphertexts so that it can immediately generate fresh tunnels to the server without re-requesting the key; an alternative would be to save the full key. Of course, if your smartphone's mobile-data plan has a 10GB-per-month data cap, and this month your phone wants to contact 5000 PQConnect servers that it has never talked to before, then you'll have to get on Wi-Fi. ## Public-key encryption for authentication PQConnect uses Classic McEliece not just to protect the confidentiality of user data but also to protect the user data against forgeries. The client sends a ciphertext to the server's public key to establish a secret session key known to the client and server. The session key is the key for an authenticated cipher that protects each packet of user data. Reusing encryption for authentication avoids the need for a separate signature system. Some references: [1998](https://eprint.iacr.org/1998/009), [2009](https://dnscurve.org), [2016](https://cr.yp.to/talks.html#2016.02.24), [2018](https://www.pqcrypto.eu/deliverables/d2.5.pdf), [2020](https://eprint.iacr.org/2020/534). ## Authenticating public keys TLS relies on DNS to be secure. An attacker that controls the DNS records for `www.your.server` (for example, an attacker that compromises the root DNS servers, that exploits continuing holes in the deployment of cryptography for DNS, or that uses a quantum computer to break pre-quantum cryptography used for DNS) can obtain `www.your.server` certificates from Let's Encrypt and can then freely impersonate `www.your.server`, even if applications stop trusting all CAs other than Let's Encrypt. "Certificate transparency" sees the new certificate but does not stop the attack. Similarly, an attacker controlling the DNS records for `www.your.server` can turn off PQConnect for `www.your.server`, or replace the legitimate PQConnect public key for `www.your.server` with the attacker's public key. The PQConnect protocol supports three approaches to stopping this attack. First, the PQConnect protocol is capable of protecting DNS itself. We are planning more documentation and software for this; stay tuned! Second, to the extent that other security mechanisms are deployed successfully for DNS, they also protect PQConnect's server announcements. Third, the PQConnect protocol lets you use a high-security name that includes your server's public key. For example, instead of linking to [https://www.pqconnect.net](https://www.pqconnect.net), you can link to a [high-security PQConnect name](https://pq1u1hy1ujsuk258krx3ku6wd9rp96kfxm64mgct3s3j26udp57dbu1.yp.to) for the same server, as long as the application does not impose severe length limits (in, e.g., certificates). Some client-side software steps are necessary to make sure that all paths for attackers to substitute other names are closed off (e.g., the key extracted from the PQConnect name has to override any keys provided by CNAMEs, and DNS responses sent directly to applications have to be blocked), but this is conceptually straightforward. ## Public-key encryption for fast key erasure: NTRU Prime Beyond encrypting data to the server's long-term McEliece public key, a PQConnect client applies another layer of encryption to a short-term public key provided by the server, to enable fast key erasure. This short-term public key uses a small-key lattice-based cryptosystem. This choice has the advantage of reducing per-tunnel costs, although this does not matter when there is a large amount of data per tunnel. The disadvantage is that lattice-based cryptography has [higher security risks](https://ntruprime.cr.yp.to/warnings.html) than the McEliece cryptosystem, and a break of the lattice-based cryptosystem would mean that keys are not erased, although this does not matter unless the attacker also steals secrets from the device. Trigger warning: If you find patents traumatic, or if your company has a policy to not learn about patents, please stop reading at this point. [Unfortunately](https://patents.google.com/patent/US9094189B2/en), [lattice](https://patents.google.com/patent/US9246675B2/en)-[based](https://patents.google.com/patent/CN107566121A/en) [cryptography](https://patents.google.com/patent/CN108173643A/en) [is](https://patents.google.com/patent/KR101905689B1/en) [a](https://patents.google.com/patent/US11050557B2/en) [patent](https://patents.google.com/patent/US11329799B2/en) [minefield](https://patents.google.com/patent/EP3698515B1/en). NIST has published [edited excerpts of a license](https://web.archive.org/web/20240331123147/https://csrc.nist.gov/csrc/media/Projects/post-quantum-cryptography/documents/selected-algos-2022/nist-pqc-license-summary-and-excerpts.pdf) that appears to cover two older patents (9094189 and 9246675), but the license is only for Kyber; meanwhile another patent holder, Yunlei Zhao, has [written](https://groups.google.com/a/list.nist.gov/g/pqc-forum/c/Fm4cDfsx65s/m/F63mixuWBAAJ) that "Kyber is covered by our patents". Fortunately, there is one lattice-based cryptosystem old enough for its patent to have [expired](https://patents.google.com/patent/US6081597A), namely NTRU. Various security problems were discovered the original version of NTRU, but all of the known issues (and some other issues that make audits unnecessarily difficult) are addressed by tweaks in [Streamlined NTRU Prime](https://ntruprime.cr.yp.to) (`sntrup`), which was published in [May 2016](https://ntruprime.cr.yp.to/ntruprime-20160511.pdf). There were not many post-quantum patents at that point. The current version of `sntrup` differs only in some small tweaks to serialization and hashing published in [April 2019](https://ntruprime.cr.yp.to/nist/ntruprime-20190330.pdf), and patent searches have found no issues here. Streamlined NTRU Prime was added to TinySSH and OpenSSH in 2019, and was made default in OpenSSH in [2022](https://www.openssh.com/txt/release-9.0), with no reports of any problems. PQConnect also uses Streamlined NTRU Prime, specifically `sntrup761`. The software uses [libntruprime](https://libntruprime.cr.yp.to). ## Formal verification Most of the PQConnect security analysis so far is manual, but symbolic security analysis of one component of PQConnect, namely the handshake, is within reach of existing automated tools and has been carried out using an existing prover, namely Tamarin. Running scripts/install-tamarin scripts/run-tamarin inside the PQConnect software package will install Tamarin and verify the handshake. See Section V of the [NDSS 2025 paper](papers.html) for more information. pqconnect-1.2.1/doc/html/0000755000000000000000000000000014733452565013732 5ustar rootrootpqconnect-1.2.1/doc/html/compat.html0000444000000000000000000002662014733452565016107 0ustar rootroot PQConnect: Compatibility
PQConnect
PQConnect: Compatibility

Backward compatibility

Preserving connectivity is critical. After you install the PQConnect client software, your machine will connect to PQConnect servers and will continue to connect to non-PQConnect servers. PQConnect is designed so that the PQConnect client software detects PQConnect servers without sending extra queries to non-PQConnect servers. (Such queries might trigger hyperactive firewalls to break connectivity.) Similarly, if you are a sysadmin installing the PQConnect server software, your machine will continue to allow connections from non-PQConnect clients.

This compatibility works using CNAME records, a standard DNS feature (for example, www.amazon.com relies on CNAME records). To announce PQConnect support for www.your.server, you will rename the existing DNS records for www.your.server (typically just an A record showing the server's IP address) under a new name determined by PQConnect, and you will set up a DNS CNAME record pointing from www.your.server to the new name. For example, www.pqconnect.net has a CNAME record pointing to pq1u1hy1ujsuk258krx3ku6wd9rp96kfxm64mgct3s3j26udp57dbu1.pqconnect.net, which in turn has an A record listing the server's IP address. Non-PQConnect clients follow the CNAME record and connect to the server. PQConnect clients recognize the CNAME record as a PQConnect announcement and make an encrypted connection to the server.

Forward compatibility

PQConnect announcements include a version number pq1. This supports smooth future upgrades in which clients are upgraded to allow a modified pq2 protocol, and then servers can freely begin announcing pq2.

Subdomains

PQConnect is not limited to www.your.server. You can also announce PQConnect support for imap.your.server, zulip.your.server, or whatever other subdomains you want within your DNS domains.

However, you cannot set up a DNS CNAME record specifically for the second-level name your.server delegated from the top-level .server administrators. DNS does not allow CNAME records to have exactly the same name as other records, such as delegation records. It would be possible for PQConnect to work around this restriction by inserting PQConnect announcements into delegation records, but currently PQConnect focuses on protecting subdomains.

Operating systems

The initial PQConnect software release is for Linux. The software installation relies on packages supplied by Linux distributions. Package names are not synchronized across Linux distributions. The installation currently understands the names for Debian; Debian derivatives such as Ubuntu and Raspbian; Arch; and Gentoo. Adding further distributions should be easy.

Support for non-Linux operating systems is planned, handling the different mechanisms that different operating systems provide for reading and writing IP-layer packets. The PQConnect system as a whole is designed to be compatible with any operating system. The PQConnect software is written in Python. The underlying C libraries for cryptography have already been ported to MacOS.

Accessing the IP layer is not the only way to implement the PQConnect protocol. Existing user-level applications access the kernel's network stack via system calls, normally via libc. It is possible to modify those network packets by modifying the kernel, by modifying libc, or by pre-loading a PQConnect dynamic library, still without touching the individual applications. Also, most applications access DNS at the servers designated in /etc/resolv.conf, usually via libc, so it is possible to modify DNS packets by changing libc, by modifying /etc/resolv.conf to point to local DNS software that handles PQConnect, or by modifying existing local DNS software to handle PQConnect (via plugins where applicable, or by code modifications). These software choices can also be of interest to apply PQConnect to applications that manage to dodge the current PQConnect software.

Applications

Our experiments have found the PQConnect software successfully wrapping post-quantum cryptography around a wide range of applications. However, there is no guarantee that PQConnect covers all applications. For example, an application might read a server address from a local file without using DNS queries, might use its own encrypted tunnel to a DNS proxy, or might otherwise deviate from the normal modular usage of DNS services provided by the operating system. These applications do not receive the benefits of PQConnect: they will continue to make non-PQConnect-protected connections as usual.

A notable example is Firefox, which automatically uses DNS over HTTPS in some cases to send DNS queries to Cloudflare. A DNS proxy (or DNS packet rewriting) can disable this by creating an IP address for use-application-dns.net; this allows Firefox to benefit from PQConnect, and is still compatible with passing DNS queries locally to a modular DNS-over-HTTPS client. A user manually configuring Firefox to use DNS over HTTPS will prevent Firefox from using PQConnect.

Transport-layer security

SSH connections, TLS connections, etc. work smoothly over PQConnect. The software managing those security mechanisms doesn't notice that everything is protected inside a PQConnect tunnel. The PQConnect software doesn't notice that the packets it's encrypting already have another layer of encryption.

VPNs

Conceptually, running the PQConnect protocol on top of a VPN protocol, or vice versa, is a simple matter of routing packets in the desired order through PQConnect and the VPN. So far we haven't written scripts to do this, but if you have specific use cases then please share details in the Compatibility channel on the PQConnect chat server.

Firewalls

PQConnect encrypts and authenticates complete IP packets, including port numbers. After decrypting a packet, PQConnect forwards the packet to the local machine on whichever port number is specified by the client. One consequence of this encryption is that you cannot rely on a firewall outside your machine to block ports: any desired port blocking must be handled by a firewall inside your machine. Note that an external firewall also does not block attackers who have compromised a router or network card between the firewall and your computer.

You may be behind a firewall that restricts which ports you can use: for example, the firewall may block low ports, or may block high ports. PQConnect is flexible in which ports it uses. The -p option for the pqconnect program chooses a client port. The -p and -k options for the pqconnect-server program choose a crypto-server port and a key-server port. All of these are UDP ports.

IP versions

Our PQConnect tests have been with IPv4, but the protocol should also work with IPv6. The PQConnect handshake packets are small enough that even multiple levels of surrounding tunnels should stay below the 1500-byte Ethernet limit on packet sizes.

Application-layer surveillance

The PQConnect server software automatically replaces client IP addresses with local addresses such as 10.10.0.5 when it delivers packets to applications running on your server. Hiding client addresses can help protect privacy against applications that are careless in handling client data, and can help comply with privacy regulations.

If you need applications to be able to check client locations to route clients to nearby servers for efficiency, one option is to provide different DNS responses to clients in different locations (using, e.g., the "client location" feature in tinydns), already pointing those clients to nearby servers at DNS time rather than having the application perform this routing. If you need to check client information in logs for abuse tracking, one option is to collate PQConnect logs and application logs, still without exposing client IP addresses to the application.


Version: This is version 2024.12.26 of the "Compatibility" web page.
pqconnect-1.2.1/doc/html/crypto.html0000444000000000000000000004557714733452565016160 0ustar rootroot PQConnect: Cryptography
PQConnect
PQConnect: Cryptography

This page explains PQConnect's top three cryptographic goals, and various aspects of how PQConnect aims to achieve those goals. There is a separate page looking more broadly at security.

Priority 1: post-quantum encryption

Attackers are carrying out mass surveillance of Internet traffic. They are saving encrypted data to break later. They are years ahead of the public in investing in quantum computers. The ciphertexts we send are irrevocably shown to any attackers monitoring the network; we cannot retroactively improve the encryption of that data.

The top priority for PQConnect is to switch as much Internet traffic as possible, as quickly as possible, to high-security end-to-end post-quantum encryption.

To the extent that some applications have already been rolling out post-quantum encryption, great! PQConnect adds another layer of defense in case that fails, a layer systematically designed for high security. But the more obvious benefit of PQConnect is for applications that are still using pre-quantum encryption or no encryption at all. PQConnect provides a fast application-independent path to post-quantum cryptography.

Priority 2: post-quantum authentication

Another important goal of PQConnect is to switch as much Internet traffic as possible, as quickly as possible, to high-security end-to-end post-quantum authentication.

The urgency of post-quantum authentication is not as obvious as the urgency of post-quantum encryption. Consider, for example, an application relying on pre-quantum signatures for authentication. Assume that the application is upgraded so that all verifiers accept post-quantum signatures, and then upgraded to replace all generated pre-quantum signatures with post-quantum signatures, and then upgraded so that verifiers stop accepting pre-quantum signatures, with all of these upgrades deployed by all signers and verifiers before the attacker has a quantum computer. There will then be no verifiers accepting the attacker's forged pre-quantum signatures.

However, the timeline for upgrades is variable and often extremely slow. For example, within web pages loaded by Firefox, the percentage using HTTPS was around 30% in 2014, around 80% in 2020, and still around 80% in 2024. There are clear risks that, when the first public demonstrations of quantum attacks appear, many applications will still be using pre-quantum cryptography, while real quantum attacks will already have been carried out in secret. Starting earlier on upgrades will reduce the damage.

Priority 3: fast post-quantum key erasure

Sometimes a user's device is stolen or otherwise compromised by an attacker. Perhaps this allows attackers to find decryption keys inside the device, and to use those keys to decrypt ciphertexts that the attacker previously recorded.

Of course, the big problem here is that secrets stored on a user device were exposed in the first place. What one wants is better protection for all data stored on the device. However, in case that protection fails, the damage may be reduced if keys are preemptively erased.

PQConnect sets a goal of having each ciphertext no longer decryptable 2 minutes later, even if the client and server devices are subsequently compromised by an attacker also having a quantum computer. Concretely, PQConnect encrypts each ciphertext using a post-quantum key that is erased by the client and by the server within 2 minutes. This erasure happens within each PQConnect tunnel, no matter how long the tunnel lasts.

For comparison, the "ephemeral" options in TLS are often claimed to provide "Perfect Forward Secrecy", but these options still allow ciphertexts to be decryptable for as long as a TLS session lasts. A 2016 study found that "connections to 38% of Top Million HTTPS sites are vulnerable to decryption if the server is compromised up to 24 hours later, and 10% up to 30 days later". Current security guides that ask TLS applications to disable session resumption do not prevent sessions from lasting for hours or longer.

Full-packet encryption

PQConnect encrypts the complete packets sent by applications, including protocol headers and port numbers. Attackers may be able to deduce the same information by analyzing metadata such as the timings and lengths of packets, but this is not a reason to simply give the data away.

VPNs and BPNs

VPNs typically share PQConnect's features of being application-independent and encrypting full packets. However, VPNs generally do not provide end-to-end security. A client sets up a VPN to encrypt traffic to a VPN proxy, but then traffic is exposed at the VPN proxy, and at every point between the VPN proxy and the ultimate server.

It is possible to manually configure typical VPN software so that a connection to www.your.server goes through a VPN tunnel to www.your.server, a connection to www.alices.server goes through a VPN tunnel to www.alices.server, etc., when this is supported by the servers. PQConnect automates the processes of announcing server support and of creating these tunnels.

In English, "boring a tunnel" means creating a tunnel by digging, typically with a tool. PQConnect is a "BPN": a "Boring Private Network".

The PQConnect mascot is a Taiwanese pangolin. Pangolins dig tunnels and are protected by their armor. The Mandarin name for pangolins is 穿山甲, literally "pierce mountain armor". Legend says that pangolins travel the world through their tunnels.

There is another use of the word "boring" in cryptography: "boring cryptography" is cryptography that simply works, solidly resists attacks, and never needs any upgrades. PQConnect also aims to be boring in this sense.

Double public-key encryption: ECC+PQ

To the extent that applications have upgraded to post-quantum public-key encryption, they are normally using it as a second layer on top of pre-quantum public-key encryption (typically X25519), rather than as a replacement for pre-quantum public-key encryption. This reduces the damage in case of a security failure in the post-quantum software: the impact is delayed until the attacker has a quantum computer.

PQConnect follows this approach. One difference in details is that PQConnect replaces typical concatenated encryption with nested encryption to reduce attack surface.

Conservative public-key encryption: McEliece

PQConnect does not use the presence of an ECC backup as an excuse for risky PQ choices. A devastating PQ failure would mean that goal #1 is not achieved.

The foundation of security in PQConnect is the Classic McEliece encryption system at a very high security level, specifically mceliece6960119; the software uses libmceliece. Among proposals for post-quantum public-key encryption, the McEliece cryptosystem is unique in how strong its security track record is: more than 50 papers attacking the system since 1978 have produced only tiny changes in the McEliece security level. Classic McEliece is also used in the Mullvad and Rosenpass VPNs, and in various other applications.

Each PQConnect server has a long-term 1MB Classic McEliece key that it sends out upon request. To prevent amplification, PQConnect pads the request to 1MB. This cost is only per-client, not per-tunnel or per-connection. The PQConnect client software generates and saves many Classic McEliece ciphertexts so that it can immediately generate fresh tunnels to the server without re-requesting the key; an alternative would be to save the full key.

Of course, if your smartphone's mobile-data plan has a 10GB-per-month data cap, and this month your phone wants to contact 5000 PQConnect servers that it has never talked to before, then you'll have to get on Wi-Fi.

Public-key encryption for authentication

PQConnect uses Classic McEliece not just to protect the confidentiality of user data but also to protect the user data against forgeries. The client sends a ciphertext to the server's public key to establish a secret session key known to the client and server. The session key is the key for an authenticated cipher that protects each packet of user data.

Reusing encryption for authentication avoids the need for a separate signature system. Some references: 1998, 2009, 2016, 2018, 2020.

Authenticating public keys

TLS relies on DNS to be secure. An attacker that controls the DNS records for www.your.server (for example, an attacker that compromises the root DNS servers, that exploits continuing holes in the deployment of cryptography for DNS, or that uses a quantum computer to break pre-quantum cryptography used for DNS) can obtain www.your.server certificates from Let's Encrypt and can then freely impersonate www.your.server, even if applications stop trusting all CAs other than Let's Encrypt. "Certificate transparency" sees the new certificate but does not stop the attack.

Similarly, an attacker controlling the DNS records for www.your.server can turn off PQConnect for www.your.server, or replace the legitimate PQConnect public key for www.your.server with the attacker's public key.

The PQConnect protocol supports three approaches to stopping this attack. First, the PQConnect protocol is capable of protecting DNS itself. We are planning more documentation and software for this; stay tuned!

Second, to the extent that other security mechanisms are deployed successfully for DNS, they also protect PQConnect's server announcements.

Third, the PQConnect protocol lets you use a high-security name that includes your server's public key. For example, instead of linking to https://www.pqconnect.net, you can link to a high-security PQConnect name for the same server, as long as the application does not impose severe length limits (in, e.g., certificates). Some client-side software steps are necessary to make sure that all paths for attackers to substitute other names are closed off (e.g., the key extracted from the PQConnect name has to override any keys provided by CNAMEs, and DNS responses sent directly to applications have to be blocked), but this is conceptually straightforward.

Public-key encryption for fast key erasure: NTRU Prime

Beyond encrypting data to the server's long-term McEliece public key, a PQConnect client applies another layer of encryption to a short-term public key provided by the server, to enable fast key erasure.

This short-term public key uses a small-key lattice-based cryptosystem. This choice has the advantage of reducing per-tunnel costs, although this does not matter when there is a large amount of data per tunnel. The disadvantage is that lattice-based cryptography has higher security risks than the McEliece cryptosystem, and a break of the lattice-based cryptosystem would mean that keys are not erased, although this does not matter unless the attacker also steals secrets from the device.

Trigger warning: If you find patents traumatic, or if your company has a policy to not learn about patents, please stop reading at this point.

Unfortunately, lattice-based cryptography is a patent minefield. NIST has published edited excerpts of a license that appears to cover two older patents (9094189 and 9246675), but the license is only for Kyber; meanwhile another patent holder, Yunlei Zhao, has written that "Kyber is covered by our patents".

Fortunately, there is one lattice-based cryptosystem old enough for its patent to have expired, namely NTRU. Various security problems were discovered the original version of NTRU, but all of the known issues (and some other issues that make audits unnecessarily difficult) are addressed by tweaks in Streamlined NTRU Prime (sntrup), which was published in May 2016. There were not many post-quantum patents at that point. The current version of sntrup differs only in some small tweaks to serialization and hashing published in April 2019, and patent searches have found no issues here.

Streamlined NTRU Prime was added to TinySSH and OpenSSH in 2019, and was made default in OpenSSH in 2022, with no reports of any problems. PQConnect also uses Streamlined NTRU Prime, specifically sntrup761. The software uses libntruprime.

Formal verification

Most of the PQConnect security analysis so far is manual, but symbolic security analysis of one component of PQConnect, namely the handshake, is within reach of existing automated tools and has been carried out using an existing prover, namely Tamarin. Running

scripts/install-tamarin
scripts/run-tamarin

inside the PQConnect software package will install Tamarin and verify the handshake. See Section V of the NDSS 2025 paper for more information.


Version: This is version 2024.12.27 of the "Cryptography" web page.
pqconnect-1.2.1/doc/html/index.html0000644000000000000000000001617114733452565015735 0ustar rootroot PQConnect: Intro
PQConnect
PQConnect: Intro

PQConnect is a new easy-to-install layer of Internet security. PQConnect lets you take action right now on your computer to address the threat of quantum attacks, without waiting for upgrades in the applications that you are using.

PQConnect automatically applies post-quantum cryptography from end to end between computers running PQConnect. PQConnect adds cryptographic protection to unencrypted applications, works in concert with existing pre-quantum applications to add post-quantum protection, and adds a second application-independent layer of defense to any applications that have begun to incorporate application-specific post-quantum protection.

VPNs similarly apply to unmodified applications, and some VPNs support post-quantum cryptography. However, VPNs protect your traffic only between your computer and the VPN proxies that you have configured your computer to contact: VPN traffic is not encrypted end-to-end to other servers. The advantage of PQConnect is that, once you have installed PQConnect on your computer, PQConnect automatically detects servers that support PQConnect, and transparently encrypts traffic to those servers. If you are a system administrator installing PQConnect on the server side: configuring a server name to announce PQConnect support is easy.

What to read next

The installation instructions for PQConnect are split between two scenarios.

If you are a system administrator (for example, running a web server), you should follow the installation instructions for sysadmins. This covers setting up the PQConnect server software to handle incoming PQConnect connections from clients.

If you are a normal user (for example, using a web browser), you should follow the installation instructions for users. This covers setting up the PQConnect client software to handle outgoing PQConnect connections to servers.

What about the combined scenario that your computer is a client and a server (for example, your computer is running an SMTP server and is also making outgoing SMTP connections)? Then you should follow the installation instructions for sysadmins.

Chat server

We have very recently set up https://zulip.pqconnect.net using Zulip, a popular open-source web-based chat system. Feel free to join and discuss PQConnect there—you can be one of the first users! Just click on "Sign up" and enter your email address. Reports of what worked well and what didn't work so well are particularly encouraged.

Team

PQConnect team (alphabetical order):

The PQConnect software is from Jonathan Levin.

Funding

This work was funded in part by the U.S. National Science Foundation under grant 2037867; the Deutsche Forschungsgemeinschaft (DFG, German Research Foundation) under Germany's Excellence Strategy–EXC 2092 CASA–390781972 "Cyber Security in the Age of Large-Scale Adversaries"; the European Commision through the Horizon Europe program under project number 101135475 (TALER); the Dutch Ministry of Education, Culture, and Science through Gravitation project "Challenges in Cyber Security - 024.006.037"; the Taiwan's Executive Yuan Data Safety and Talent Cultivation Project (AS-KPQ-109-DSTCP); and by the Academia Sinica Grand Challenge Projects AS-GCS-113-M07 and AS-GCP-114-M01. "Any opinions, findings, and conclusions or recommendations expressed in this material are those of the author(s) and do not necessarily reflect the views of the National Science Foundation" (or other funding agencies).


Version: This is version 2024.12.27 of the "Intro" web page.
pqconnect-1.2.1/doc/html/mascot.png0000644000000000000000000002761414733452565015740 0ustar rootrootPNG  IHDR_R| IDATxռgteu&s9"gs#,JV-Y%DZxx<ϣ53Oֳgfyƣ,-%*آ l6;hth \7trfS"` Ω:Uv{FcpF*+4(6˛iV}9la@r _O.,E:?;Lz@]8n7JմoDQ]M]{:&Ç@}|YWHIO/h+WpqIXZD-Bфi26uɱ?t>设`gf]`-%lvjBv>k6e2J2 ZR}l 77akOc>IG_ި?+VJ9[zf:}zZGerN5͟Spk!Sn3l1l9h@Ǒ-ο+fq}#ّ$׆of^>LS*ԥ)mg$3D|zSMOK QNOyfrR$xSS/`S(3L}@>NοuSڑSlzgί,'!#KWr .: v珮itgZ.ljr[A#Rs5stba* ŮH4kgy{_~x兓Vv7 YܧϟIUJ(zLN6X#ZVxaUDYÈ}vy#?&=搇R Y5e"3n,VzBETY#L78^{wTU}rX(/ddRvyooe"rnT6\f4b'av"bk6p`#RUO&!,l|N$:2Sg"럞EQG.o2%YMV@5_͍Zmr̝)KQYgHo#)ӬHs ?W}rzΕduH:pS9ɟ]KzW-ztr";dG;rMcѦbv^zaۆd"v3|fOi).]$={.sc ZC͌Z<0(&qGu] JLƥ+dj\́#8F'$:٢cц^@47*ȱ& ~qz̙QU=rp{6,T=w:yn;TH USr*VpmѰ%_nhgpd3R,VPݸ3 !o-*bZhuWYZ@\Xji9)i>.b8M+j,e%vIUvt_x2UɺL>9l;$Y'e9k]]-jtYvT&x҈84 6~` 5w!6 BMBCNцRBĦ;gXK)psA BƼY؝safVZBTQ.ɜf%9W( 9-^]bIv˗/]۟kRE6x%ͩcRӤ¹JFC-UU>]k7F ,B1a)AnZVRju[٢;jCH^/Wt}f'PbSl h_YoŊ/GT$cN||gz191Jyy^R%me],W7KͱTZkh"8~5 ȔU۬&ZMTj5D%32pąk =ϝh)iʼn2.&W,ǡ羁`E>Sozm{+yDQT"摊4@H g9NgW +j96 O VGnhn!ʅ OJ[j+T:bO6U74DV4hE,Sk`O$EybbU4<[w{?[vƮO=E-+iWؕ7Mx%c=mLad `l4Ӽݠ$ L3jQufnv:m_ 0"TZ,2URaYBIt}Zۮ>ٍ|P*\3j6Y?t((BY]፜ 4Z8%G/^n{byb)&{zr33}=E;~B5CY5ckXwdgCV2f9\Cc3Vlaut4,4]XR5A#]NZWUEśyaW{-}||Ek |Ǐ>^ǧ}bZAHg+=1@pmF펵+b4 t`jq;F@$Őw=ع+B}fpضL(2 ["*k?)ϾVԲqYx5b1|I[ߪŞV l5*m!.%\OiHU&'Rz"lBgUghdi.39 $NM=n?ۚ3{ wݙ]Z F"F,JŹZ]b`g!qve.j0y8 " jG|yB4::ƅVfύ⹸ mjH ?l1FbaQ5 H w "{b؃@aHa8@V/&.b^3JqlOXwԘ^lz$ԓg#Q~𥿴Μvrz#rIUz](pt-8Bza3kr3SS4W-׭`"/2Q-&nL^kfHꪦK̲ yL鲳.ac8{}who[n8xL2jA8},Uht^<8m AWFphEۚ]wAϬ&Uށ_(I_jZb*phl̷&ƗIZ+Hve N!/k]U;sp8Ҙ|Ř0V*@LSb}`e+k@Wl):orfBM&2coV;[;'&wkV n&#:~y-SXH1X++dw jTY!Gg﫜6N3)LMBv*2;^+z'ٕ4i6GHY4\CAybd1mD5DnfVjcK2I.o\n7.^0:Lx|;kQg U͍X_|~D!"(;~s7?xmˏ;X8-3&]p f{ j-@Vb2pqڑVL!ı_Q8IJ}1F&ɊBe/a*QжBzmx߅p~麓Ϯ(:`܍T$) wuA4V1-2DrEkv- [PSq_k!E&(xOM閕$Yv!W/XuhK1lE1=ˍ=qhd"׈L&GId[&詑>(PqXSH$ u)ѣJLX𮱧+ߗ{Hƣ9r?0 M'FŤ9w 8NjV,M: nM6ԼQkM4Vk j;êӆnC)IQ·Xٱզ>}ҫE3b32;6$9,I*R7f6^v%νt}KǞNPe"ߥӜ"AiEz; 2+; n+KU<屹KvfG7W{8uթ@w"ыp%!)0@$&b/}Re(5^Y^j0Hl$cH ĤOn-VXu⾓{X}_FR zޅ(\jhpBzZ*?gZ#%K1gejfĂFZE`Os3p\xE5Q dxMnyPOO+w T J74Tд"{212D}z@* -g1 h-X_7PcTH@tC*yOsG T[ )$ #SvEC Gb;#iNۛYtGjw@֚*MV g Ѿ>z-i!%-fmjWKL̮_܊QqޤaKiߜQ;xj<_8>488h4x=d~0V* w&h\e˳ܙ5!_0 -bo R&TE haAE"j7 lg>QX6%"jg)2rtU`QL\"-WKL~5)SO߳ltI!i,?^(N|OͿi4v7~sgg&tU}hsj0ni\؀>1B2c4᳚~~ctY^ϼ8;8Tgt( υtqyIV}˓cϭpczɏ2 mY]NLFCKB[XnXH.Т7eP= Rb o(r {Py(EmI.2wZ CN^cF⦮ሲΧN `@ E388BӻO}n91ю}3`97ƒO,3qqnKM+Og>Ce)b^ى[p;; "f!6Y=*`$]BGIDAT ֞=Eݗv=.Aj[ZMLB;lvS5{+3ziR*@֥>1)[[yt[Ik[⑁\*?2s*&:ۭ5hY~7?I`OߺzzJlsYrGKi?{l[FsŨfME|a 6M'u-͂ZdPAa M"ה|um ch#BUS`#SܔLn6sFB; { Uػ"#L/uU3uUXm $Sl)_.Yɟo?BaX,tyr?^ ixs9eו?o~d,nh5[_UikRr-ݻ#D]R)wSS]#O|q`h,VP^79A/MR6F+c#6`&VEz~NkkM ˲o`hopocc3/ju3MtL;;]eֶ4 W[eJSm i:0[Y 2IsDOಾv-7Rwxs#*Kes0 Rrk|M#S88 ]hW@[ օ4l'tU'C[9 ж0Sy$`0H* D.Tkb-lr^. ܕ[QHJsH+]nG4Li1z`|^y~Z6u4Zf7YIj$J x0ݟ<띓sz;ُ.RlԈ 6=$Ʀd #l:ngMsD5~&?!]ɖe箮m XJU3rN}E$}Q֍5Y.<@]p7;JҐCCX"i`Z ~Aru3ű\wf'i<%[ZMW,QR278zɻ ZKOz HQy倠L\t@T.tZqA ҢF1kY ҥ>ô@I엸ŧt@h>o0}m1RPqj$U=9-B*\zQ0D5S=&]}2 y V*PmQ`Ǝ<`Y,Wl ZLF;N6<ߙ>ד 7r`5%`.mdcx"^+88Tx io:*&LFn>m+)ٳ;K ǥbQe xLR)T@"^#bZO75 FVVUfX$FM#ѵThI6-.kP:dҴzȞ{{}/`cB @˅A鵠:qqr&K {=nNO˔ e˖5' dJ]Dcک@)ڦ^ )*㚢ʻ{/w2tGoǩ=VK1{}[кTuKЈ^e Bt^&4hiۚ'8ܶ7QS2opS)Veݙa]3k l)ÿs׀,_.ʦWP1aWe ^gսn9@.7=%@4   SRlVmc3KD (˥$Sp<Z:I\޼:qv<>O,;@DSгw'H9 1%l|!Vh0 mXRh &CRJY2z)%LL4@K!h ty[Ewn70!"s>/[$͑H]]jmpaFvCMk5<q߯P=?lC0-TV_aw3*j5& j_lןJ50"RtA6T2Y>x`h< NbvM\UP\%+%*"呐j+uR3Im+/9+E8bR·=U>둳ItV7wqh0毮rxPaw&.eǏxbEm3ՑVWEbРЄu@Gu8Q(2't!ZlKw}.AZ[g&;~C{ė&%dwkم|<p@P]tv_ҶuPLuRQVH T\RV4:2SBC]tnuD|@ J/?62wuOѓ8y5ܧvݛ&̤l4K]UU3;xe oOreH1 ϥ=sSlȚj6ci)B;?;>+8~n.3['L/ϻ2=8S/bn*dPZZ:A3j3>1ed5[jfUO ŰΞHwfJ[4x8l?~I wBvy2l!ii[@U_XSĀ-jɲZj:]ubf[ʆHVzlӹ Z[[9=^燜DDKw{?v  IsA؈%YcRJխsspfRt%MtqԭWTah6SWd,Jߝ;LR\=wP8ݻއAc6j\w+!SfE38ahž`({9ݨiv"wMs HLe琊'HZJ{{sƱ&No.Olnki2&H j$IQ׶V"*YR\ ۬QOKՊ!t8nq?o}`A pʘz]$E.,/]񀃬մBMq' G!CF&EЮ=}4E;\V݆}`@ ^p8Ht]/WjGƦ/?7{膌M#}ѻ|WY<>h|Pgg~̯|x$"I !zmlk^ۊ/Jn{IOB6̝H/m9?||yN\v͞M{ l|LEēbpsT l؍xڅ8JgKA0H5uƦX@+sv71Iϑ1Ȋ,x($-W{AG3LXlfC,IENDB`pqconnect-1.2.1/doc/html/papers.html0000444000000000000000000001241414733452565016112 0ustar rootroot PQConnect: Papers
PQConnect: Papers

The paper "PQConnect: Automated Post-Quantum End-to-End Tunnels" by Daniel J. Bernstein, Tanja Lange, Jonathan Levin, and Bo-Yin Yang will appear at the Network and Distributed System Security (NDSS) Symposium 2025.

Further reading:

PQConnect builds on work by many further researchers. See the NDSS 2025 paper for references.


Version: This is version 2024.12.27 of the "Papers" web page.
pqconnect-1.2.1/doc/html/security.html0000444000000000000000000002161714733452565016474 0ustar rootroot PQConnect: Security
PQConnect: Security

Cooperative security

PQConnect is not in competition with existing application-specific cryptographic layers, such as TLS and SSH. PQConnect adds an extra cryptographic layer as an application-independent "bump in the wire" in the network stack. Deploying PQConnect means that the attacker has to break PQConnect and has to break whatever cryptographic mechanisms the application provides. This layering has an important effect on security decisions, as explained below.

Primum non nocere

Modifying cryptography often damages security. This damage can easily outweigh whatever advantages the modification was intended to have. For example, upgrading from ECC to SIKE was claimed to solidly protect against quantum computers, but SIKE was shown 11 years after its introduction to be efficiently breakable. There are many other examples of broken post-quantum proposals; out of 69 round-1 submissions to the NIST Post-Quantum Cryptography Standardization Project, 48% are now known to not reach their security goals, including 25% of the submissions that were not broken at the end of round 1 and 36% of the submissions that were selected by NIST for round 2. As another example, OCB2, which was claimed to be an improvement over OCB1, was shown 15 years after its introduction to be efficiently breakable.

However, for an extra layer of security, the risk analysis is different. A collapse of the extra layer of security simply reverts to the previous situation, the situation without that layer.

We are not saying that it doesn't matter whether PQConnect is secure. We have tried hard to make sure that PQConnect is secure by itself, protecting applications that make unencrypted connections or that use broken encryption. We are continuing efforts to to improve the level of assurance of PQConnect, and we encourage further analysis.

What we are saying is that, beyond being designed for security benefits, PQConnect is designed to minimize security risks.

Beyond the basic structure of PQConnect as a new security mechanism, the rest of this page describes some PQConnect software features aimed at making sure that PQConnect will not damage existing security even if something goes horribly wrong. There is a separate page regarding PQConnect's cryptographic goals.

Virtualization

System administrators often run hypervisors such as Xen to isolate applications inside virtual machines. The PQConnect software supports client-in-a-bottle and server-in-a-bottle modes. In these modes, the PQConnect software runs inside a virtual machine while protecting connections to and from the entire system. The overall data flow of network traffic is similar to what would happen if PQConnect handling were delegated to an external router, but the router is within the same machine, limiting opportunities for attackers to intercept unencrypted traffic.

Beware that virtual machines are not perfect isolation. For example, various Xen security holes have been discovered, and timing attacks have sometimes been demonstrated reading data from other virtual machines.

Memory safety

The PQConnect software is written in Python with careful use of libraries. Using high-level languages such as Python limits the level of assurance of constant-time data handling and key erasure, but these are not memory-safety risks.

Cryptographic operations are carried out via libraries that follow the SUPERCOP/NaCl API, rather than libraries whose APIs inherently require dynamic memory allocation. The relevant library code has been systematically tested under memory checkers such as valgrind. Memory checkers do not necessarily exercise all code paths, but all data flow from attacker-controlled inputs to branches or memory addresses is avoided in the specific public-key cryptosystems used in PQConnect.

File safety

The PQConnect software is primarily memory-based. The filesystem is used in a few limited ways (e.g., storing the server's long-term keys), with no data flow from attacker-controlled inputs.

Port security

Malicious users on a multi-user machine, or attackers compromising applications, can bind to specific TCP ports or UDP ports, preventing PQConnect from using those ports. However, the operating system prevents non-root users from binding to ports below 1024, so you can simply choose ports below 1024 for running PQConnect. For example, you are probably not using port 584 ("keyserver") or port 624 ("cryptoadmin") for anything else; you can use those for a PQConnect server.

Privilege separation

Privileges are used at run time by the PQConnect software components that hook into the networking stack and that create and read/write packets on the TUN network interface. The PQConnect software employs privilege separation to isolate code that requires these privileges from the rest of the software. Most of the code is run in a process as a non-root pqconnect user. Untrusted data arriving on the network is piped from the root process to the non-root process for handling.

Privilege limitation

When the PQConnect software is run under systemd (as currently recommended), various external constraints are applied to the system calls used by the software, although this is generally less limiting than running in a virtual machine.


Version: This is version 2024.12.26 of the "Security" web page.
pqconnect-1.2.1/doc/html/sysadmin.html0000444000000000000000000003362214733452565016453 0ustar rootroot PQConnect: For sysadmins
PQConnect: For sysadmins

These are instructions for adding PQConnect support to your existing server, to protect connections from client machines that have installed PQConnect. These instructions also cover PQConnect connections from your server.

Prerequisites: root on a Linux server (Arch, Debian, Gentoo, Raspbian, Ubuntu); ability to edit DNS entries for the server name.

Quick start

Here is how to download, install, and run the PQConnect server software. Start a root shell and run the following commands:

cd /root
wget -m https://www.pqconnect.net/pqconnect-latest-version.txt
version=$(cat www.pqconnect.net/pqconnect-latest-version.txt)
wget -m https://www.pqconnect.net/pqconnect-$version.tar.gz
tar -xzf www.pqconnect.net/pqconnect-$version.tar.gz
cd pqconnect-$version
scripts/install-pqconnect
scripts/create-first-server-key
scripts/start-server-under-systemd

Then edit the DNS entries for your server name, following the instructions printed out by create-first-server-key. This is what lets PQConnect clients detect that your server supports PQConnect.

To also run the PQConnect client software:

scripts/start-client-under-systemd

This has to be after install-pqconnect but can be before start-server-under-systemd. The client and server run as independent pqconnect-client and pqconnect-server services.

Testing

The following steps build confidence that your new PQConnect server installation is properly handling PQConnect clients and non-PQConnect clients. (If you are also running the PQConnect client software, also try the quick client test and the detailed client test.)

After start-server-under-systemd, follow the instructions printed out by create-first-server-key, but apply those instructions to a new testing-pqconnect server name in DNS pointing to the same IP address, without touching your normal server name.

On another machine running the PQConnect client software: Test that dig testing-pqconnect.your.server sees a 10.* address instead of the server's actual public address. Test that ping -c 30 testing-pqconnect.your.server works and sees a 10.* address.

On the server, run journalctl -xeu pqconnect-server and look for a key exchange with a timestamp matching when the PQConnect client first accessed the server. Optionally, run a network sniffer on the server's public network interface to see that the client's pings are arriving as UDP packets rather than ICMP packets.

Test the server's normal services from the client machine and, for comparison, from a machine that isn't running PQConnect yet. Note that web servers will typically give 404 responses for the testing-pqconnect server name (because that isn't the server's normal name), but you can still see that the web server is responding. Many other types of services will work independently of the name.

Finally, move the testing-pqconnect configuration in DNS to your normal server name, and test again from both client machines.

PQConnect ports

The PQConnect server needs clients to be able to reach it on two UDP ports: a crypto-server port (42424 by default) and a key-server port (42425 by default). You may wish to pick other ports: for example, ports below 1024 for port security, or ports that avoid restrictions set by external firewalls.

To set, e.g., crypto-server port 624 and key-server port 584, run

scripts/change-server-cryptoport 624
scripts/change-server-keyport 584

before running start-server-under-systemd, and edit your DNS records to use the pq1 name printed out by the last script.

If you are running the PQConnect client software: The PQConnect client uses port 42423 by default. To set port 33333, replace pqconnect-client with pqconnect-client -p 33333 in scripts/run-client-core.

Server-in-a-bottle mode

The PQConnect server software supports a "server-in-a-bottle mode" aimed at the following common situation: You are running multiple virtual machines (VMs) on one physical machine (the host). The VMs are managed by a hypervisor that tries to isolate each VM, to protect the other VMs and the host. The VMs communicate on a private network inside the host. The host uses network-address translation (NAT: e.g., SNAT or MASQUERADE with iptables, along with 1 in /proc/sys/net/ipv4/ip_forward) to resend outgoing network traffic from the VMs to the Internet, so that all of the VMs appear as the same IP address publicly. Each VM is providing services on some ports on the public IP address: e.g., the host is forwarding IMAP to one VM, forwarding SMTP to another VM, etc.

What server-in-a-bottle mode does is run a PQConnect server in its own VM to protect connections to all of the other VMs (and to any services that you are running outside VMs). Compared to running PQConnect in each VM, server-in-a-bottle mode has the following advantages: PQConnect is installed just once on the machine; there are only two new ports to configure for the machine, instead of two new ports per VM; to the extent that the hypervisor isolates VMs, the other VMs are protected against potential issues in the PQConnect software.

The steps to set up server-in-a-bottle mode are as follows.

Create a VM. Create and start a new persistent VM (called pqserver, for example) running an OS compatible with the PQConnect software (for example, Debian), following your favorite procedure to create a new VM. Give the VM its own address within the internal network.

Ensure connectivity. Test that this VM can contact another VM via the public IP address and port for the other VM. (The whole point here is to have PQConnect protecting traffic that it will deliver to the other VMs.) If this test does not work, presumably the port-forwarding configuration is only for traffic arriving from the Internet; add forwarding rules that also apply to traffic from this VM, and try this test again. You can do this without configuring anything outside the VM: just copy the host's port-forwarding configuration into this VM, adjust as necessary (for, e.g., the VM having different network-interface names, and for copying any PREROUTING rules to OUTPUT rules), and set up a script to copy and adjust any subsequent changes to the port-forwarding configuration.

Choose ports. Choose two public ports for PQConnect. Double-check that you are not using these ports for anything else: for example, you don't want to accidentally cut off your existing SSH server on port 22. For concreteness, these instructions take crypto-server port 624 and key-server port 584.

Forward packets for those ports into the VM. You'll want to be super-careful for this next step: this step is working outside the VMs (e.g., working on dom0 under Xen). This step assumes that the host is using iptables for packet management. Run the following both from the command line now and in a boot script to apply after reboot:

publicip=1.2.3.4
pqserver=192.168.100.94
for port in 584 624
do
  for chain in PREROUTING OUTPUT
  do
    iptables -t nat -A $chain -p udp \
      -d $publicip --dport $port -j DNAT \
      --to-destination $pqserver:$port
  done
done

Replace 1.2.3.4 with the host's public IP address, and replace 192.168.100.94 with the VM's address on the host-internal network.

This iptables command configures DNAT so that UDP packets (-p udp) destined to these two ports (--dport $port) on the public IP address (-d $publicip) are resent to the same ports on the VM.

Run PQConnect in the VM. Inside the VM, follow the quick-start installation of the PQConnect server software, but run

scripts/change-server-cryptoport 624
scripts/change-server-keyport 584
echo 1.2.3.4 > /etc/pqconnect/config/host

right before running start-server-under-systemd. As before, replace 1.2.3.4 with the host's public IP address.

This sets up the server to run on the specified ports inside the VM (you can also use ports different from the public ports if you want, as long as you forward the public ports appropriately), and to forward decrypted packets to the public IP address.

Forward decrypted packets out of the VM. Inside the VM, install iptables for packet management, and run the following (with enX0 replaced by the VM's name for its network interface), both from the command line now and in a boot script to apply after reboot:

sysctl -w net.ipv4.ip_forward=1
for proto in tcp udp icmp
do
  iptables -t nat -A POSTROUTING -p $proto \
    -s 10.42.0.0/16 -o enX0 -j MASQUERADE \
    --to-ports 40000-50000
done

This iptables rule arranges for PQConnect's decrypted packets to be delivered to dom0 in a way that allows PQConnect to see replies to those packets. Specifically, the PQConnect server software chooses various 10.42.* addresses to send decrypted packets to the public IP address; this rule will rewrite those packets as coming from 192.168.100.94 (using port numbers to track the original addresses), and will undo this rewriting for packets sent in reply. The 10.42 is a default in the PQConnect server software; it's used only inside the VM, so it isn't an address you have to change for your configuration.

Test. Now test PQConnect using a new testing-pqconnect server name. Then edit DNS to announce PQConnect support on whichever names are used for the services provided by this machine. If the DNS names for some VMs are managed by other people, let those people know that they can enable PQConnect support for those names by simply modifying the DNS entries. You don't have to upgrade all of the names at once.

Client-in-a-bottle mode

The PQConnect client software supports a "client-in-a-bottle mode" that runs in a VM to protect outgoing connections from the whole machine, analogous to the server-in-a-bottle mode for the server software. Documentation coming soon!


Version: This is version 2024.12.26 of the "For sysadmins" web page.
pqconnect-1.2.1/doc/html/topleft.png0000644000000000000000000007546214733452565016133 0ustar rootrootPNG  IHDRzik IDATxgՕ>|\tOΣh49@BL2gpX{q]^ۻclp&K5FLOwOT! ]{ؿɷ?deӑ%V#[En}i:7pDrQTAs[{xBߊy<g3ӧ6UorZ2mp.ҩB,B(?qT:l2'#iIJn!ǬᑏJj7҉)yv壟ogs23$ ͸jYQG&p0y)* 8ٳBʢʤ-ZbUsڅY*IɌiDfm)h ,`{q_G>u'eb OwԵhtEO؂27: 1UJNQ:btSyπ VOEJCh)JGXfbT>:ì{OVS.:yE"Ⱦ \ Ͻ_%\v\f~pRF]նVSi1Q$/' ӏO)<|F>6ajCY΍axX,M9rN`/)έ|XA@fL.`Vitdhhi,_Y|uwd{voI\շsуd+WU5;qV+ܾLspcܭ4}gIЁ3%չB*܊JN]\wRon&7u;9%e `;V?G;ÿ|*EU ΨʽXӧٟ#8J0^!;Z2X[&(aDŽh[*) O27z+ `/)߽}sx]Ñl*ϼݲk/\x-?Ǜ97-x\w>?|x\;aTQGSrVBl ̘> 56ʮgrW]}4nPw6ftl;>,iZ+l>#B>ws w"TW@`7>f&árxlvE'\7|X|><#Ln{6u|6Wr4ji㚜1sC)x`KCtW dD(j<~'V7y'*P6I6;1:]G2"y|oDL} ٕ1?gO=M7.p. ,} 'L*JBd:j휎FwUY '+*ß<ȧo2][d F46T~.B=c3Ȓh͛e 2Wqx˃ec(hn]"HexG诙5Iݍb>l;;Һ ;qHGhzNű(WbۆvϽ[%*:,1prTg`γoiN % {׼MZ/TƂ[;s:v i[Ѿ`boݻvOm:_~}ʫS1HLLh a&Ajel8}O~s6Ѱ9zZXڼH>P~gLoHM7*9%c$)%NNC3{zT']}S쮣(̯ T}JOuu5V;+m2pQ1x6Z2~W+u$5WO\?ќ:?2Ldd<0[߲k%N1^zn-;zC++]j^͜KWuu_ d-2FMNJth-0$a]R" 75NB#W:ؕs33IĘf%`I' ɑ)lC:<8gУޮb1+[1ۋ[k³t`EbIl6+'v׭[cyDPPX &cHfU8;RO@}5hGN儎7 :>[W3#xLבG4rʚolJWN͙PSƳa'Υ{\ s֪HJalX&_}7˛Fo?5wZ+~ga#f%<h0wH['m| <lW.{.,VK܉;݇În95S~kzU B>-xM^ەy1MHHsYdR9'uz1k-Œj:A T"H >?Ł%nejsnZ[_ȈO>IuUKjFh١,V-",:;Jo<qRmZP_De7I6g1d,4p\KS3ȴ4T+цF /}>p;YY1"X` 1:'5!682zՊ$&1IJ8 ,ɤEiEn+L}xʩl&7cEfl2X?unFaiLCWD5%Y,'K{)N HE*qUy]/V_yRݾfJ*-xb9J~?.;3j~3Ώx˸?P6ߟ]V)5NBl w]\e}z4\ɫ{?۶o8 b!e{N3z3)I___¹rI}nVWS"i]MΧ\.mz1ZyU 2S*SA*;7tKKZ0)T;icGlcSpT\80 ab2gxM`0|nw6du!g_u6o_6_:3G&Kӟ֊[fjc_^#)hl:irĄwb FPP`#&r^&c:;DĀX@,iB2]dlZ k[mCniU=՟]>0^z;}]dK b%VW Dg{3\}C0 Wy]˻)9Sd3:;lagW+ P(XruU6L,( R)S[*Ttvꪢ+OEȓSiqb"a#:E"B D*MuM\W. LK[DyCVg#Ld[/%΍[+-3wm1FjBWA es-xaM,+kx#+^K%+nTm{P_SY 4TO08DІi"K4ݶx;jLq߾>7?Ցl65c]6uNM`]‚ 0IV"L ,aYDA>JIo]ۧG?ko^| ~>>.hanMrD*Yư/>)NMغKyr2r&  wBP(\\Uqm0uK4,^J10X 95#&<_o>#9&pluӎ&LၗQGe D,(ߊ˪#]ƴ!BO}aw[;|d9YsZ0`0L0Qr[eYن'Tb2,pIdke(ج,l"`" 4/\fWXz 9w[}=*y!2ёLAU8/S*QuzR/\W"QHW)+;uxab:C9TRe 0 뗗0 GÓ7ltMv#Sck>^N5$8"Bbof_^7Vf=ۡ9Q|yUkC^(fwsΙ> EXc r8qqXĬa Hc$gkك>2FGяn/O~mώӷJMsw`PW3)VR"ky#d( LD4J3[e[^ZR[NTkAw(cɼl* agtp;XҀit~%ͽD d;t'ifx1 )eŠb26`^>6Ǖ7yVPb8- [8_H5= O-+UAǻ_mm"_0):mMcnU#t͂PR⅋]񒦲GĪ[_;aRljp<-gڥ>??M:sv㹢͹KZDddg(ϑl<=y3E #T U9ls\鐟ˈ}bb|; mY @.DʡM/r5L ]_rVOvtú7z8e3_6^9KňĊڽšK:hl)E$K%插JTYe jWaOLL 9YߦӰPrA`^޼,ZWꇜv8q*%#XDe6pwobVCѼpn8n0HyPuAD'@̢VyyۿFR_(ϲ:"γڭe`t*>gK!sUp5^v"B`~õzxLPǤ @`$dR|vDG2[gk/~A1a8^ %EaJUfྡ?(A7g PdT?yv}ι, sw4$T֚%]l@UbTUw\l\&CRD4m;ptd&xj*,P@&Ry-)s&@{;ஷ-yKgϯ_szaʂN ʛGSW! u}!a{иgft f0K !~5PZIZXn`Qw\M'?Cl@kDV.Z4XO 71Mv gKyB86K>G9 E?p[o^$ `WmmG_8%p1;)%f(T~<1gPզj;O/YX c*K˒%1b(@Q+{N y޳5KuWM>_<012]gU 9W`"Y JH!TВ96-Z_ s'fi|IM3/%+g q!(G>RXs2łJd$;61@2:y8JICHŦSq)]vm~wJj̰\Yl2} <EeSz.j߳m}y=V OZ]SC[66l|k&V3F':^XJӾشIho8]u*BM6,K~1,)]G͍9rƚ5j ҡ/V/r* >rh&&1 |sMJ)9S*@ PbfyL=2er $~ؘi>t>#?xO" 2 6Tլ,S~fQVh-56u $~V`l"F)C#pc1J,&6{w[wMC.t^J=aX1Oc+"C7^[>uIS__@#~vM-ri>mA$*sh_bII3(Y^ؗUhE:k_dUvm2?yJvqUƘw\5d2j]E1g|6fpcFfT@% b7,!N+5 Sc$?1L&36GcߘI(` &yCWƊ/H}eQKemͽTC1ag6|Fk=( px]_ oBkTrCk}מ4?mV˲LIJф%Vܢo?x5<^Y?~7Aθ\ ](!$(3xV*z(\fI@93!DTewXNoxsG &?vgaPa{pk~C!Ϻyrׯ[9֖XXgT l:@SKKeT2$A64sҊ^=Fly0Z2Cix0p 3<{e. eƧ֒Qp~q9W|7:384M&obGDj&O N4]};zJM-@`E[G~R >+=Wc@0~.Xy GFju0ehb@HC6rs*#LrJ`Z6Mc(q)c;jq{8߀9}vIGݡyymz w[jeЭLE~Mq]~#U"u;z߿wuupZ.SRWfo:I(41\<1 j^b2H5̤J$[ Ȥ 3bȴYQpF:x:0`cqٕL^jBu݂aU+oo͈ٛY g"!0RpgZ dT[ ٜ& Wd6IXX!މULh@%e•v(>.!TdJ*9\0Ҿ%0޳@ =޹F. {E˽{=+lݑ&4s&iJf"5K*J|1 @ҾRLH#:MbE|G O͘q ]}u굢:K"ѽumoRўn8.ӎy}󙓁xmZW :c!@&h+]P |'P4ظ2y./uɗ~`0.Z<\hz~K(.L6jcVST!78ٹ}#}=7>5G~_>1m5h^n#b@j0=_M-a1sfy? oN Q>cRE;rSyYT1gYǮo2Sif#?l͒ xyG&G1`xmH>JsbSPMht[+^>9nfE]1u4n21BIP`Z,Aa,M_K9&hk0XM}sykeGz|5 C:ux "hAcV̊p^&6cF깬ygP xSHP گߴ6XJ bkCح&tB,5Μ"sk]1k)S85psXLev}ٛ b(7dMW٘{S/ZPζEb!0:=IU]{c/?rݢkmZo'SrRQUZXG3/v Q- ՁZ:{h]jl`n:&B^7gd,~[s}mEֶLT~"|gv_+zd믒&ZpY AAZLNjdF*(xn̴7@"Kil8}hpjgD(Ҩ%^ޑ5F=sj'+泫-˓~c3\=EF_;iR&CX@veNʔD=_B_Bހ#NfcP'<Ӿ)S߱4%0`!LSsE ci8Y2EDU%Q0-?nW:Zw{,;_8 h2[ K4LW_OCQH`U㫵VyfKJV7o|K_xKs,gݶν8g"ܾq[8Ƈ2|mfux*>}^! " "R(q,#XGE# =J =/w﻽䅔(*|y䞻Ϲ{Zkw)I+ >4Dުy.f TǸ'7C 4ً+o>d3JyCms"5nJ) =ԣ Z>*rCוY|>4ϵ_<E_bԈg83&[ْ&k4Adw})k=n1Ũ7]s-*:ɽ.:j(6J66N ۟\E50QH<ݧ>g`X<Ħe2 }poy뷜W_d=WFin6o!'juW:NbfL3,Yݶ^M8>Vuqf!dhj*wXqN6ĮhiH |3u+7W~m|++x u]J,ԩr؆ufC5 %e o 1$ۓP5S>hn2-_$]3_Ϳ|idEj;z]ZK.[0` n9*:'|PV|naI9t`wy3cH,ޏ_,(F rmyx(tyiM /}zy]nXȴ>b?Y˴ ^, `nzv8CJ%SvyֽYqP-!|FځII2bT7mQ wx|G==?´9 /'] U"eA`&B/)}$&`0L\:l}he8rS"w,WG%*U=៭Ox c-1A;GdYiEDB#!Rjs!/&d0rD8k$܊:<چ;aas7+Q:qܛ[G|Ml >*M IDATڋNq&e2*$X>o<%a ^So?#єhR;w.ʱM.ϗ{D!n!_$ GPu4l>zoX|w-`øyJZxx-,`aG> 4jW-,l]w1x?6xwrFS x)  'ɹr!y-_-焎3$cDYhF)υP)SI 0򢩪ia,k&gpw'pn˸"q 2`M]g|\B3ᯬ;눲.n|kwr=WdBgF1BB=}:vJ:"|Džql@ÛjwB'Zf\N~2(MeUG6:a!YX2ZYynd t Җ}BiRG 0)PpTΛ^.ԸE+Bh2whiSkZG_ OIVcˎK*(Qf%?1ANz0$)?ʇsc]?t=m~PTr^DyУ'c5ٵn!4_H;iBj%I_I4]219[RU= grT2Qs0WCdBՓ=b~샑do?(;-Y-u64.%Ĺ?{ %i!fH5 y,v~EE2yFrU@o(7-T8i`ڟ `e_(04jmi{=\FM-WasoV=CPP?pX/)'x]—n5$4V 큤Yeg;Ƨ?1Gk_\2S ~*Dxh!9d7wܔJ̩}71٬Yp!fHZ15&G&3QD)!J)2Ȉa,7I=;e\q=RqgS{˝\m_,~79%"|޻ g e"߳F?ݵ Z+8}E'!>=.> [)1!q9DC_;:#f!״ͽ_W%{TŠ&|dXftL3MiT@(={ ϥj jws[?.`BSsTn7X:L_byj)3l Cji:>$,vL/,[[*w|:˖$ /ckeΔ,ۛn>4I}ǵ%a/s`Ӧ.q}7-C *KpP 7G ل"v+͉:Cdg,Ds/o $OcCXŀ8!нߟ6luAaʿaE, x(D"d\{!^8p]2}õC]I p&5:o:c K$~C \]07Z]BHo=Z,E|BҨuS`Er~r؄_|9s񝱫WgWn2.h|Dެg *MN/g nj8NcYjZʃ/9dS'KI:[E@1C;zvl}faU uջFcv +9)aC'>.öDi^ iL-j2V+dRppKz|Nֺ<D͒0»“C<7y~1,% AN~ڀ;rNp"g=]ڦғ?m3v^qg>/^]xXqNࡕo|}˶;]? I}?1p O?O3=_X=/4G:LĻ׮"> %"f)>8VvNhG?1`YDA)qyE/lfaJ0@GdP$Z`sJtc#k)qAB)$C\)7Ēǀ e/diˇ*eJ3:>L!JsLϋꓳde܄dh BMQ܂W0N4ZKc gOlߗwås2)vkqSO=[np%i3>xad4a*.T;ghk]sċnXbc\FIkbP*},Abʟ咰nޞW1y#f!fxBgsz" p&sK[,0w}ܧ׾e7\z3:^5=CB_>~ќHx2D rTu ?y~I4з۷>hcs(4i嬚:.`փd>r8jkZm-5,7ư׍PJ"ib38홾!)GH#X1{*F?ɸPH$i6 |$feUDE籤2%]{ۜ-ejI30$∐uT|i9~zpY,.߭JL# MeNد1c:Yfѯ-Ifw^̘捗_{'ΪgO=UО6)4=b.3%|ǙI'$ ViDD\కU6mybm.7rLe܀t!möQ#;YM2bK? xnصڵaKn{Zbٿn|~* &,=^%LB6quCSK %LjE#YH)Q2fٖ^Jd[lT(UISL7č J\ΫAq!DGO ` q6ˋ2q]fӣ6r႖s\|܉B-}+^CD- v8GBN°*"UQbRD`aљVdQ네v*Ĕ]4e;2V':QM=(ϯe懗TK1gKmۛR>r){5H H9 U6|{Lϸ{~/Ɯ}w?̔r7uA= FoJ 4]2c+ZZ##wOvʕ+NWi?߉M6gt(̖#we1a:Z5Xχ|st2tXJO |0P)ڄKTbn5Haf Z9CԔedn;Qq+e/?WZ5Z]1%sѡdҼ-w a!!>ǢL%yӏ;2ikccT(IrcZؼ$:GՅ2x $TdĞxEKdrCz~@2EUU~3t 4LW^k{=t^;a#ueBk`W)/N`}-668yJ`]/v㕢05sJj+Dž8,춢tW)w2o-஛t$lm٘y)Z;!Da;al2fsiLV?f: 7o%.Eq$[<_Qz=b7[3.lD`KazV_H,˾6V_55Q\Z58Fs\Q(B>} xjuYض Cosoֳ<XC0cCK.Xpe^L=9]]+|x`n_i.= ONT8mȶb x;v67#Sc9̬u@@ޘFryEF$c3+<@ Wf:^Km}P\WDf@D1؍Ao5?۹6ŏ Yhxƀ8DdESF7O5t>XeAxt ʐp(5L-$+_u$hN64S("d'1#hNe&hsy9C,& !e[Nq3r$6644VXq!:Ь/du_ںu _QH H^q~j/*c0TmtG迆n=DwXըX$rW3\CEFF":cEWʱս[`-"q( 8̙9ȇK[FY6F癫sՒ7s]^j; n-dD+.ddT.$W9vT!x(fωmLZ})ր  yNaǴ)O/jL1$wDZSqE vg:bō{+x{HfQ(5d65:K߻vY֮;cg4Kf,2"x,\KfF*gRTti~49<G`@C,}),4bjclP~+ʼ6)=GEnˌKˑr<2 q321]8OU\IsYR_D+ 93:> "wka~n8>ɼҪԐ,=R^H/ omO.$vIv[tisu|? Piiۑv3-H:,3lngSWbFe {kw ظ&)ɮLAf,6\'ŇM{%)m]VuoҜ}s%a^~=$ 0EYKRPY>us嗢GUpEغ`+lޜC!}⶯Iɱ%Ţ~EƏӷBge8[|St "Di 9HFƮ–D ~H@_O*?ה=xf!$f|r if.pȈ%p|]`iXD?_(=ue]f@,Q"od!XF |{tmSuƔɳaGMdb؂6L/ fkƦ|?nߎ!4zYx.A..YNb6Z3EJֻVl/2i+j`Ir)j&sveoND+3b WNVj%>(}S۫-Mfwˇw?O|EYY`TfD%2`Dc*zMdixֳ51^ޞUrfw|R{ҽ^32榼f6E/J:, 9رrtP*T⳦Aa_#*;߆sxˊ3s|&WJn{eFMppto])Y֬}t.8_3H|9эكݷQV\}2vcDEIDATjLGܖw}2j7*8C35x@ 鱵[+a'j :f0O]☠犚L,TR %̺:V[8"w5SH>/qOpy,—cǴxY)/N'*PJ0ժ)6@0V@e^!bzdcMudg?U /ҭE5=̋ed+Qc o&ڭM1Kf =g;K|G~路nmSq{IfC9Šӵr*',8ϽMܐ?G;|ЪO\{GgAP:w#eD8rx<qs7''O_CK -xj3u]C-i Z#OT q>UA+-O=?va42cؖ86(kS?f8˥D|+425zB !o28B&sq!hal@CjȮ3~P*Sˇ#/oMfٍgu"TUNm|yڄ-nJʩT*5Vy q~m^0;aCtݞϞ~#gG[_]BnVU |ő߅b6Cp+@EWEI:SpJrĥQ[ņV/y_x=WEm;0bũ00<-) ݶ̋xf2j^BTTMWlZƈ$" X'TCQ]{}W_s?Ku_p!vzo^I1[Y&Yc*\bw֠$r0k k&o ` 3ZC61ƉȄ.Cİb-zļ)N yhBtY`<ކ^2 :`QіXZ@Dž|MbwԖ-WH7wmܗe]O|dťw3<ѩXVڶo/+M^c_+b'#ظT;A1}{ 4RA񏎔ӻv6|AC)+JuPyJS0lFڤ&fA"P0dd#2rKȓ.:jP/f r?# Fv9[* 8I)Uݵ7F%9{+sVe{|Ug_l,/FYBu!nP/Ͷ=MlZɥJ;R.C=zlN(uy pXkVR-\dQxrg 𥉉dNY;ӂ(8aƩv b X6'CbFP f"0ـZoՂԖb8,9FA*fz4헣ԝ„}v9}^1ǃ]QpUaZ wMڀd\:BfWTЂC:*T^FJ->- \jXd8M7\bP-t;R5hg  `3 Al06{ So<[/Fi dgK !.5)ؔ=u'VZׯX=~o6W_V>ʓ|3OL|oSdQ?k"qqzϋR [e81N=|9<7\Wza:/ O+3僵B1lQ$n [G~>(\2YE[5ƙ5g A"@-ĕJ+Bo0bӫjWBGvo:$[g~/]Q\q_X>?_;kuDu|T nޢl)Mk|OCkl`135ҲQ,sT̗ YV+T& L6L.PrGպtR=P$IL0xJ;̅,1OL'26*BD+R91{9^7phT}D;ڐX_^Q/fw'ɡ7VpBC}puαZ?^ VߜeiIY 8Veݱ{_+׺K>jgm&KKuG Js͸2!`pf 8I)i\D89eqFQ9c@ L i{?5Vp$a$kJo>1{ڣn?W퟿l!wSG* 5P"9 1\BeLu4ݐW۾̈́{Vmޞ;˴ď;O6v,a\WW4r42 cx{ם 0OfRaYYC#nwG_6x,hMvw[O"{~teTx>x9YVX=F vQ%m6X #]qiy*)?y?FkmN^jc"DMSy0%S#y;' ŒHq %iL];?HM*XICpKݥo9t^*ϟھگ5놳ꑇ[>7 zfG8^B )p= 0"D?Jޡ(C)F05B=i~<cL:{)2"GUOpmD=éR5[IUf:HY]ޜEҩȫ@/,7"1D(b'$ F867 )rr̮5bXOipmo-P9`հMQjK%CË7ܟã/4tLUe`DQWƒY-&[g8mԖfy9`fr^*sڛ/cȱ=4Vĵ]NZ8b v`Æj+RU ۳aH`KmJrxjFknVY v D@{X7^k^=$s+ " dEEJ Zb1>W=Ȑ`%JR?Kk fգe:e;"^rU}zQ$b0mmz{u.O\d|1p0٘blG溋9RX`F | |tX蜏ROE`Jcr[7UFye9b ^ 7׈l >Q׎;iE {M9ŧߟ{NgΊ\h@zck)ʁ鴦$P,qx= q-Pb--T)-rX*6QM.pH1P.$%^dw㉦xz)F7ަ]=?b$g3OsyM<.,iJ `,Ϋ*>w蹀4څс?+]f+ydP hWCd Q$WЫDٹSpJ>i C_?XMOC,(Qu>6䤛t8_`1)":ƽƔ "هk).5_%9.@,'VV}kCܨulQQBvWԞED9!'/T)hjz595ёLa+%#&rqYL2%Bњ}FK9/*OU:Uemc}띡TRzdXo?tzU WPd,Ožg~G.ޮ~fb\Dbhו3S}"7]HP`e~˩XҟR9BO9ܵlt^ykĽouH?ůt1m<ȜqμewS9Ǔ`MgłQ JyUYd ˿7Q&_=?[<ޡ|uIRތG;-僜jP Qu@sc3vҾb[z"ڃ 7bZ zn"O9Hߤ±0F/ɛDh hʆ _OcCt~]UL`볳.G(yz-dyE&7\WLO@vT2it'/{#iW)_r)lj4 Gۂj h7!jqPyl ̎Jgup]YM?lKZŠ<{Pa0sW?njw?X:xh,b ag6I%9ϩ(A.WbMQ tP@ǨthHy)T0 *NX-x+2;U2e7[_Xn{4Rl$=\?}fCoq?ɧ7[ܷxo*qm^UJ 7 &֔$PCk&nu e]>)HI76z,0*/o{W:UTs]|7prA&( sֹ3^q~?_XEqe<Nj,Vg׌6rߪ $'ož)}eU]5/kA/,*F=їFK=3_qQԉ8?y{7\[.nݸ޻~Qis7o:}A7mpd;pن(yJ )kpoL%mhM?GnyGfcͲ2vlELpC:WkBm殯}:YJVKu;;nܼpЍrJ롒*覀}S$' VCRx !n(F,鷊K*QPqŒE=g8Xj]/Ɔ3B͚?tucw|w܂j_1;!M"`+rbY$cjQDD/1_@HgIG\P"SKdpO.y̹7yP-xG'- YÙo~b8.2;(ml?EJÖ6(IQLMmmu(~_72*N&΀ӆI`TZs2(Lepl=A@ L,x6pqEse҆qIOQܛV+ܘ~֬n1~jj_/X5/aTNC5s׽R+P>œ!r;(c*?œ}gpZYcO9緖9q38yү7ށ8Ħ)+> MV.^6|;RT(fM?\8_ C]rOLW=h+QHIQ{1oRo6<4-O>Z9tczP22Z~1 œBP>; u{+PRH B9 A@tnܭFS"ad5E ,@LtFn^iV+px Y[iaS"iaa.l!W:ثܥ4$UUJF_/MсdR&%?,NM}j  # ِT'`cKkѲRRpcy% /wsd-׍^iOMcboosH4OAx[_̚gG]1-<7 +-f8.?4 05<󜜜uow[9$p|IENDB`pqconnect-1.2.1/doc/html/user.html0000644000000000000000000001553214733452565015604 0ustar rootroot PQConnect: For users
PQConnect: For users

These are instructions for setting up the PQConnect client software. This automatically protects outgoing connections from your machine to servers that support PQConnect.

Prerequisites: root on a Linux machine (Arch, Debian, Gentoo, Raspbian, Ubuntu). The software does not support other operating systems yet, sorry.

Quick start

Here is how to download, install, and run the PQConnect client software. Start a root shell and run the following commands:

cd /root
wget -m https://www.pqconnect.net/pqconnect-latest-version.txt
version=$(cat www.pqconnect.net/pqconnect-latest-version.txt)
wget -m https://www.pqconnect.net/pqconnect-$version.tar.gz
tar -xzf www.pqconnect.net/pqconnect-$version.tar.gz
cd pqconnect-$version
scripts/install-pqconnect
scripts/start-client-under-systemd

That's it: you're now running PQConnect.

Quick test

Try curl https://www.pqconnect.net/test.html; or click on https://www.pqconnect.net/test.html from a browser running on the same machine. Your machine running PQConnect will say Looks like you're connecting with PQConnect. Congratulations!, where a machine without PQConnect would say Looks like you aren't connecting with PQConnect.

Also try connecting to a non-PQConnect server (for example, https://testwithout.pqconnect.net) to see that non-PQConnect connections work normally.

Detailed test

If you have dig installed: Try dig +short www.pqconnect.net. Your machine running PQConnect will say

pq1u1hy1ujsuk258krx3ku6wd9rp96kfxm64mgct3s3j26udp57dbu1.pqconnect.net.
10.43.0.2

(or possibly another 10.* address) where a machine without PQConnect would say

pq1u1hy1ujsuk258krx3ku6wd9rp96kfxm64mgct3s3j26udp57dbu1.pqconnect.net.
131.155.69.126

(where 131.155.69.126 is the actual www.pqconnect.net IP address).

Try ping -nc 30 www.pqconnect.net. Your machine will print bytes from lines such as

64 bytes from 10.43.0.2: icmp_seq=2 ttl=64 time=120 ms

again showing a 10.* address.

If you have a network sniffer such as tcpdump installed, start sniffing the network for packets to and from IP address 131.155.69.126:

tcpdump -Xln host 131.155.69.126 > tcpdump-log &

Use wget to retrieve a web page via HTTP, first without PQConnect and then with PQConnect:

wget -O test1.html http://testwithout.pqconnect.net/test.html
wget -O test2.html http://www.pqconnect.net/test.html

Then kill the tcpdump job and scroll through the tcpdump-log output. You will see that the first connection uses TCP packets to and from 131.155.69.126.80, meaning port 80 of IP address 131.155.69.126, with an obviously unencrypted request (search for GET and you will see GET /test.html, Host: testwithout.pqconnect.net, etc.) and an obviously unencrypted response, while the second connection uses encrypted UDP packets to and from port 42424 of IP address 131.155.69.126.

Non-systemd alternatives

Running the client under systemd is currently recommended because it applies some sandboxing, but you can instead run

scripts/run-client &

to more directly run the client. Logs are then saved in pqconnect-log in the same directory. If the computer reboots, the client will not restart unless you run scripts/run-client again.


Version: This is version 2024.12.26 of the "For users" web page.
pqconnect-1.2.1/doc/papers.md0000644000000000000000000000411714733452565014605 0ustar rootrootThe paper ["PQConnect: Automated Post-Quantum End-to-End Tunnels"](pqconnect-20241206.pdf) by Daniel J. Bernstein, Tanja Lange, Jonathan Levin, and Bo-Yin Yang will appear at the Network and Distributed System Security (NDSS) Symposium 2025. Further reading: * Tanja Lange and Jonathan Levin, ["PQConnect: Automated Post-Quantum End-to-End Tunnels"](20241227.pdf), invited talk at International Collaboration on Post-Quantum Cryptography and Cybersecurity: Retrospect and Prospects, 2024. * Tanja Lange, ["How to fit an elephant into a Smart car – PQC for small devices"](https://hyperelliptic.org/tanja/vortraege/20220627-esa-lange.pdf), invited talk at ESA Workshop on "Secure Communications for Space Missions in the Post-Quantum Era" at European Space Research and Technology Centre (ESTEC), 2022. * Tanja Lange, ["Code-based cryptography for secure communication"](https://hyperelliptic.org/tanja/vortraege/20220427-oaxaca.pdf), invited talk at Algebraic Methods in Coding Theory and Communication, 2022. * Daniel J. Bernstein and Tanja Lange, ["The transition to post-quantum cryptography"](https://hyperelliptic.org/tanja/vortraege/slides-dan+tanja-20220401-transition-16x9.pdf), invited talk at International Distinguished Lecture Series at EECS at National Taiwan University, 2021. * Tanja Lange, ["Transitioning to post-quantum: How PQC affects protocols and what we can do today?"](https://hyperelliptic.org/tanja/vortraege/icmc-21.pdf), invited talk at International Cryptographic Module Conference 2021. * Jonathan Levin, ["PQConnect: An Automated Boring Protocol for Quantum-Secure Tunnels"](https://research.tue.nl/en/studentTheses/pqconnect), master's thesis, Eindhoven University of Technology, 2021. * Daniel J. Bernstein, ["Internet: Integration"](https://pqcrypto.eu.org/deliverables/d2.5.pdf), PQCRYPTO deliverable D2.5, 2018. * Daniel J. Bernstein, ["The post-quantum Internet"](https://cr.yp.to/talks.html#2016.02.24), invited talk at PQCrypto 2016. PQConnect builds on work by many further researchers. See the NDSS 2025 paper for references. pqconnect-1.2.1/doc/readme.md0000644000000000000000000000762314733452565014555 0ustar rootrootPQConnect is a new easy-to-install layer of Internet security. PQConnect lets you take action right now on your computer to address the threat of quantum attacks, without waiting for upgrades in the applications that you are using. PQConnect automatically applies post-quantum cryptography from end to end between computers running PQConnect. PQConnect adds cryptographic protection to unencrypted applications, works in concert with existing pre-quantum applications to add post-quantum protection, and adds a second application-independent layer of defense to any applications that have begun to incorporate application-specific post-quantum protection. VPNs similarly apply to unmodified applications, and [some](https://mullvad.net/en/blog/stable-quantum-resistant-tunnels-in-the-app) [VPNs](https://rosenpass.eu/) support post-quantum cryptography. However, VPNs protect your traffic only between your computer and the VPN proxies that you have configured your computer to contact: VPN traffic is not encrypted end-to-end to other servers. The [advantage](crypto.html#bpn) of PQConnect is that, once you have installed PQConnect on your computer, PQConnect _automatically_ detects servers that support PQConnect, and transparently encrypts traffic to those servers. If you are a system administrator installing PQConnect on the server side: configuring a server name to announce PQConnect support is easy. ## What to read next The installation instructions for PQConnect are split between two scenarios. If you are a system administrator (for example, running a web server), you should follow the [installation instructions for sysadmins](sysadmin.html). This covers setting up the PQConnect server software to handle incoming PQConnect connections from clients. If you are a normal user (for example, using a web browser), you should follow the [installation instructions for users](user.html). This covers setting up the PQConnect client software to handle outgoing PQConnect connections to servers. What about the combined scenario that your computer is a client _and_ a server (for example, your computer is running an SMTP server and is also making outgoing SMTP connections)? Then you should follow the installation instructions for sysadmins. ## Chat server We have very recently set up using Zulip, a popular open-source web-based chat system. Feel free to join and discuss PQConnect there—you can be one of the first users! Just click on "Sign up" and enter your email address. Reports of what worked well and what didn't work so well are particularly encouraged. ## Team PQConnect team (alphabetical order): * Daniel J. Bernstein, University of Illinois at Chicago, USA, and Academia Sinica, Taiwan * Tanja Lange, Eindhoven University of Technology, The Netherlands, and Academia Sinica, Taiwan * Jonathan Levin, Academia Sinica, Taiwan, and Eindhoven University of Technology, The Netherlands * Bo-Yin Yang, Academia Sinica, Taiwan The PQConnect software is from Jonathan Levin. ## Funding This work was funded in part by the U.S. National Science Foundation under grant 2037867; the Deutsche Forschungsgemeinschaft (DFG, German Research Foundation) under Germany's Excellence Strategy–EXC 2092 CASA–390781972 "Cyber Security in the Age of Large-Scale Adversaries"; the European Commision through the Horizon Europe program under project number 101135475 (TALER); the Dutch Ministry of Education, Culture, and Science through Gravitation project "Challenges in Cyber Security - 024.006.037"; the Taiwan's Executive Yuan Data Safety and Talent Cultivation Project (AS-KPQ-109-DSTCP); and by the Academia Sinica Grand Challenge Projects AS-GCS-113-M07 and AS-GCP-114-M01. "Any opinions, findings, and conclusions or recommendations expressed in this material are those of the author(s) and do not necessarily reflect the views of the National Science Foundation" (or other funding agencies). pqconnect-1.2.1/doc/security.md0000644000000000000000000001325214733452565015162 0ustar rootroot## Cooperative security PQConnect is not in competition with existing application-specific cryptographic layers, such as TLS and SSH. PQConnect adds an _extra_ cryptographic layer as an application-independent "bump in the wire" in the network stack. Deploying PQConnect means that the attacker has to break PQConnect _and_ has to break whatever cryptographic mechanisms the application provides. This layering has an important effect on security decisions, as explained below. ## Primum non nocere Modifying cryptography often damages security. This damage can easily outweigh whatever advantages the modification was intended to have. For example, upgrading from ECC to SIKE was claimed to [solidly protect against quantum computers](https://eprint.iacr.org/2021/543), but SIKE was shown [11 years after its introduction](https://eprint.iacr.org/2022/975) to be efficiently breakable. There are many other examples of broken post-quantum proposals; out of 69 round-1 submissions to the NIST Post-Quantum Cryptography Standardization Project, [48% are now known to not reach their security goals](https://cr.yp.to/papers.html#qrcsp), including 25% of the submissions that were not broken at the end of round 1 and 36% of the submissions that were selected by NIST for round 2. As another example, OCB2, which was claimed to be an improvement over OCB1, was shown [15 years after its introduction](https://eprint.iacr.org/2019/311) to be efficiently breakable. However, for an _extra_ layer of security, the risk analysis is different. A collapse of the extra layer of security simply reverts to the previous situation, the situation without that layer. We are not saying that it doesn't matter whether PQConnect is secure. We have tried hard to make sure that PQConnect is secure by itself, protecting applications that make unencrypted connections or that use broken encryption. We are continuing efforts to to improve the level of assurance of PQConnect, and we encourage further analysis. What we _are_ saying is that, beyond being designed for security benefits, PQConnect is designed to minimize security risks. Beyond the basic structure of PQConnect as a new security mechanism, the rest of this page describes some PQConnect software features aimed at making sure that PQConnect will not damage existing security even if something goes horribly wrong. There is a [separate page](crypto.html) regarding PQConnect's cryptographic goals. ## Virtualization System administrators often run hypervisors such as Xen to isolate applications inside virtual machines. The PQConnect software supports [client-in-a-bottle](sysadmin.html#client-in-a-bottle) and [server-in-a-bottle](sysadmin.html#server-in-a-bottle) modes. In these modes, the PQConnect software runs inside a virtual machine while protecting connections to and from the entire system. The overall data flow of network traffic is similar to what would happen if PQConnect handling were delegated to an external router, but the router is within the same machine, limiting opportunities for attackers to intercept unencrypted traffic. Beware that virtual machines are not perfect isolation. For example, various Xen [security holes](https://xenbits.xen.org/xsa/) have been discovered, and [timing attacks](https://timing.attacks.cr.yp.to) have sometimes been demonstrated reading data from other virtual machines. ## Memory safety The PQConnect software is written in Python with careful use of libraries. Using high-level languages such as Python limits the level of assurance of constant-time data handling and key erasure, but these are not memory-safety risks. Cryptographic operations are carried out via libraries that follow the SUPERCOP/NaCl API, rather than libraries whose APIs inherently require dynamic memory allocation. The relevant library code has been systematically tested under memory checkers such as valgrind. Memory checkers do not necessarily exercise all code paths, but all data flow from attacker-controlled inputs to branches or memory addresses is avoided in the specific [public-key cryptosystems](crypto.html) used in PQConnect. ## File safety The PQConnect software is primarily memory-based. The filesystem is used in a few limited ways (e.g., storing the server's long-term keys), with no data flow from attacker-controlled inputs. ## Port security Malicious users on a multi-user machine, or attackers compromising applications, can bind to specific TCP ports or UDP ports, preventing PQConnect from using those ports. However, the operating system prevents non-root users from binding to ports below 1024, so you can simply choose ports below 1024 for running PQConnect. For example, you are probably not using port 584 ("keyserver") or port 624 ("cryptoadmin") for anything else; you can [use those](sysadmin.html#ports) for a PQConnect server. ## Privilege separation Privileges are used at run time by the PQConnect software components that hook into the networking stack and that create and read/write packets on the TUN network interface. The PQConnect software employs privilege separation to isolate code that requires these privileges from the rest of the software. Most of the code is run in a process as a non-root `pqconnect` user. Untrusted data arriving on the network is piped from the root process to the non-root process for handling. ## Privilege limitation When the PQConnect software is run under systemd (as currently recommended), various external constraints are applied to the system calls used by the software, although this is generally less limiting than running in a virtual machine. pqconnect-1.2.1/doc/sysadmin.md0000644000000000000000000002406414733452565015145 0ustar rootrootThese are instructions for adding PQConnect support to your existing server, to protect connections from client machines that have installed PQConnect. These instructions also cover PQConnect connections _from_ your server. Prerequisites: root on a Linux server (Arch, Debian, Gentoo, Raspbian, Ubuntu); ability to edit DNS entries for the server name. ## Quick start Here is how to download, install, and run the PQConnect server software. Start a root shell and run the following commands: cd /root wget -m https://www.pqconnect.net/pqconnect-latest-version.txt version=$(cat www.pqconnect.net/pqconnect-latest-version.txt) wget -m https://www.pqconnect.net/pqconnect-$version.tar.gz tar -xzf www.pqconnect.net/pqconnect-$version.tar.gz cd pqconnect-$version scripts/install-pqconnect scripts/create-first-server-key scripts/start-server-under-systemd Then edit the DNS entries for your server name, following the instructions printed out by `create-first-server-key`. This is what lets PQConnect clients detect that your server supports PQConnect. To also run the PQConnect client software: scripts/start-client-under-systemd This has to be after `install-pqconnect` but can be before `start-server-under-systemd`. The client and server run as independent `pqconnect-client` and `pqconnect-server` services. ## Testing The following steps build confidence that your new PQConnect server installation is properly handling PQConnect clients and non-PQConnect clients. (If you are also running the PQConnect client software, also try the [quick client test](user.html#quick-test) and the [detailed client test](user.html#detailed-test).) After `start-server-under-systemd`, follow the instructions printed out by `create-first-server-key`, but apply those instructions to a new `testing-pqconnect` server name in DNS pointing to the same IP address, without touching your normal server name. On another machine running the [PQConnect client software](user.html): Test that `dig testing-pqconnect.your.server` sees a `10.*` address instead of the server's actual public address. Test that `ping -c 30 testing-pqconnect.your.server` works and sees a `10.*` address. On the server, run `journalctl -xeu pqconnect-server` and look for a key exchange with a timestamp matching when the PQConnect client first accessed the server. Optionally, run a network sniffer on the server's public network interface to see that the client's pings are arriving as UDP packets rather than ICMP packets. Test the server's normal services from the client machine and, for comparison, from a machine that isn't running PQConnect yet. Note that web servers will typically give 404 responses for the `testing-pqconnect` server name (because that isn't the server's normal name), but you can still see that the web server is responding. Many other types of services will work independently of the name. Finally, move the `testing-pqconnect` configuration in DNS to your normal server name, and test again from both client machines. ## PQConnect ports The PQConnect server needs clients to be able to reach it on two UDP ports: a crypto-server port (42424 by default) and a key-server port (42425 by default). You may wish to pick other ports: for example, ports below 1024 for port security, or ports that avoid restrictions set by external firewalls. To set, e.g., crypto-server port 624 and key-server port 584, run scripts/change-server-cryptoport 624 scripts/change-server-keyport 584 before running `start-server-under-systemd`, and edit your DNS records to use the `pq1` name printed out by the last script. If you are running the PQConnect client software: The PQConnect client uses port 42423 by default. To set port 33333, replace `pqconnect-client` with `pqconnect-client -p 33333` in `scripts/run-client-core`. ## Server-in-a-bottle mode The PQConnect server software supports a "server-in-a-bottle mode" aimed at the following common situation: You are running multiple virtual machines (VMs) on one physical machine (the host). The VMs are managed by a hypervisor that tries to [isolate](security.html#virtual) each VM, to protect the other VMs and the host. The VMs communicate on a private network inside the host. The host uses network-address translation (NAT: e.g., `SNAT` or `MASQUERADE` with `iptables`, along with `1` in `/proc/sys/net/ipv4/ip_forward`) to resend outgoing network traffic from the VMs to the Internet, so that all of the VMs appear as the same IP address publicly. Each VM is providing services on some ports on the public IP address: e.g., the host is forwarding IMAP to one VM, forwarding SMTP to another VM, etc. What server-in-a-bottle mode does is run a PQConnect server in its own VM to protect connections to all of the other VMs (and to any services that you are running outside VMs). Compared to running PQConnect in each VM, server-in-a-bottle mode has the following advantages: PQConnect is installed just once on the machine; there are only two new ports to configure for the machine, instead of two new ports per VM; to the extent that the hypervisor isolates VMs, the other VMs are protected against potential issues in the PQConnect software. The steps to set up server-in-a-bottle mode are as follows. **Create a VM.** Create and start a new persistent VM (called `pqserver`, for example) running an OS compatible with the PQConnect software (for example, Debian), following your favorite procedure to create a new VM. Give the VM its own address within the internal network. **Ensure connectivity.** Test that this VM can contact another VM via the public IP address and port for the other VM. (The whole point here is to have PQConnect protecting traffic that it will deliver to the other VMs.) If this test does not work, presumably the port-forwarding configuration is only for traffic arriving from the Internet; add forwarding rules that also apply to traffic from this VM, and try this test again. You can do this without configuring anything outside the VM: just copy the host's port-forwarding configuration into this VM, adjust as necessary (for, e.g., the VM having different network-interface names, and for copying any `PREROUTING` rules to `OUTPUT` rules), and set up a script to copy and adjust any subsequent changes to the port-forwarding configuration. **Choose ports.** Choose two public ports for PQConnect. Double-check that you are not using these ports for anything else: for example, you don't want to accidentally cut off your existing SSH server on port 22. For concreteness, these instructions take crypto-server port 624 and key-server port 584. **Forward packets for those ports into the VM.** You'll want to be super-careful for this next step: this step is working outside the VMs (e.g., working on `dom0` under Xen). This step assumes that the host is using `iptables` for packet management. Run the following both from the command line now and in a boot script to apply after reboot: publicip=1.2.3.4 pqserver=192.168.100.94 for port in 584 624 do for chain in PREROUTING OUTPUT do iptables -t nat -A $chain -p udp \ -d $publicip --dport $port -j DNAT \ --to-destination $pqserver:$port done done Replace `1.2.3.4` with the host's public IP address, and replace `192.168.100.94` with the VM's address on the host-internal network. This `iptables` command configures DNAT so that UDP packets (`-p udp`) destined to these two ports (`--dport $port`) on the public IP address (`-d $publicip`) are resent to the same ports on the VM. **Run PQConnect in the VM.** Inside the VM, follow the [quick-start installation](#quick-start) of the PQConnect server software, but run scripts/change-server-cryptoport 624 scripts/change-server-keyport 584 echo 1.2.3.4 > /etc/pqconnect/config/host right before running `start-server-under-systemd`. As before, replace `1.2.3.4` with the host's public IP address. This sets up the server to run on the specified ports inside the VM (you can also use ports different from the public ports if you want, as long as you forward the public ports appropriately), and to forward decrypted packets to the public IP address. **Forward decrypted packets out of the VM.** Inside the VM, install `iptables` for packet management, and run the following (with `enX0` replaced by the VM's name for its network interface), both from the command line now and in a boot script to apply after reboot: sysctl -w net.ipv4.ip_forward=1 for proto in tcp udp icmp do iptables -t nat -A POSTROUTING -p $proto \ -s 10.42.0.0/16 -o enX0 -j MASQUERADE \ --to-ports 40000-50000 done This `iptables` rule arranges for PQConnect's decrypted packets to be delivered to `dom0` in a way that allows PQConnect to see replies to those packets. Specifically, the PQConnect server software chooses various `10.42.*` addresses to send decrypted packets to the public IP address; this rule will rewrite those packets as coming from `192.168.100.94` (using port numbers to track the original addresses), and will undo this rewriting for packets sent in reply. The `10.42` is a default in the PQConnect server software; it's used only inside the VM, so it isn't an address you have to change for your configuration. **Test.** Now [test PQConnect](#test) using a new `testing-pqconnect` server name. Then edit DNS to announce PQConnect support on whichever names are used for the services provided by this machine. If the DNS names for some VMs are managed by other people, let those people know that they can enable PQConnect support for those names by simply modifying the DNS entries. You don't have to upgrade all of the names at once. ## Client-in-a-bottle mode The PQConnect client software supports a "client-in-a-bottle mode" that runs in a VM to protect outgoing connections from the whole machine, analogous to the server-in-a-bottle mode for the server software. Documentation coming soon! pqconnect-1.2.1/doc/user.md0000644000000000000000000000647314733452565014300 0ustar rootrootThese are instructions for setting up the PQConnect client software. This automatically protects outgoing connections from your machine to servers that support PQConnect. Prerequisites: root on a Linux machine (Arch, Debian, Gentoo, Raspbian, Ubuntu). The software does not support other operating systems yet, sorry. ## Quick start Here is how to download, install, and run the PQConnect client software. Start a root shell and run the following commands: cd /root wget -m https://www.pqconnect.net/pqconnect-latest-version.txt version=$(cat www.pqconnect.net/pqconnect-latest-version.txt) wget -m https://www.pqconnect.net/pqconnect-$version.tar.gz tar -xzf www.pqconnect.net/pqconnect-$version.tar.gz cd pqconnect-$version scripts/install-pqconnect scripts/start-client-under-systemd That's it: you're now running PQConnect. ## Quick test Try `curl https://www.pqconnect.net/test.html`; or click on from a browser running on the same machine. Your machine running PQConnect will say `Looks like you're connecting with PQConnect. Congratulations!`, where a machine without PQConnect would say `Looks like you aren't connecting with PQConnect`. Also try connecting to a non-PQConnect server (for example, ) to see that non-PQConnect connections work normally. ## Detailed test If you have `dig` installed: Try `dig +short www.pqconnect.net`. Your machine running PQConnect will say pq1u1hy1ujsuk258krx3ku6wd9rp96kfxm64mgct3s3j26udp57dbu1.pqconnect.net. 10.43.0.2 (or possibly another `10.*` address) where a machine without PQConnect would say pq1u1hy1ujsuk258krx3ku6wd9rp96kfxm64mgct3s3j26udp57dbu1.pqconnect.net. 131.155.69.126 (where 131.155.69.126 is the actual `www.pqconnect.net` IP address). Try `ping -nc 30 www.pqconnect.net`. Your machine will print `bytes from` lines such as 64 bytes from 10.43.0.2: icmp_seq=2 ttl=64 time=120 ms again showing a `10.*` address. If you have a network sniffer such as `tcpdump` installed, start sniffing the network for packets to and from IP address 131.155.69.126: tcpdump -Xln host 131.155.69.126 > tcpdump-log & Use `wget` to retrieve a web page via HTTP, first without PQConnect and then with PQConnect: wget -O test1.html http://testwithout.pqconnect.net/test.html wget -O test2.html http://www.pqconnect.net/test.html Then kill the `tcpdump` job and scroll through the `tcpdump-log` output. You will see that the first connection uses TCP packets to and from `131.155.69.126.80`, meaning port 80 of IP address 131.155.69.126, with an obviously unencrypted request (search for `GET` and you will see `GET /test.html`, `Host: testwithout.pqconnect.net`, etc.) and an obviously unencrypted response, while the second connection uses encrypted UDP packets to and from port 42424 of IP address 131.155.69.126. ## Non-systemd alternatives Running the client under systemd is currently recommended because it applies some sandboxing, but you can instead run scripts/run-client & to more directly run the client. Logs are then saved in `pqconnect-log` in the same directory. If the computer reboots, the client will not restart unless you run `scripts/run-client` again. pqconnect-1.2.1/handshake.spthy0000644000000000000000000001003314733452565015235 0ustar rootroot/* Model of PQConnect Handshake ====================================== */ theory PQCHandshake begin builtins: hashing, asymmetric-encryption, symmetric-encryption, diffie-hellman functions: aeadenc/4, aeaddec/4, kdf/1 /*kdf2 and kdf3 represent the 2nd and third*/ /*key output by the kdf on a given input*/ functions: kdf2/1, kdf3/1 equations: aeaddec(k,n,aeadenc(k,n,m,ad),ad) = m /* PKI */ rule Register_static_pq_pk: [ Fr(~ssk) ] --> [ !PQ_Ssk($S, ~ssk), !PQ_Spk($S, pk(~ssk)) ] rule Register_static_npq_pk: [ Fr(~ssk) ] --> [ !NPQ_Ssk($S, ~ssk), !NPQ_Spk($S, 'g'^~ssk) ] rule Register_ephemeral_pq_pk: [ Fr(~esk) ] --> [ !PQ_Esk($S, ~esk), !PQ_Epk($S, pk(~esk)) ] rule Register_ephemeral_npq_pk: [ Fr(~esk) ] --> [ !NPQ_Esk($S, ~esk), !NPQ_Epk($S, 'g'^~esk) ] /* These rules model key comprimse */ rule Reveal_npq_ssk: [ !NPQ_Ssk(A, ssk) ] --[ NpqSskReveal(A) ]-> [ Out(ssk) ] rule Reveal_pq_ssk: [ !PQ_Ssk(A, ssk) ] --[ PqSskReveal(A) ]-> [ Out(ssk) ] rule Reveal_pq_esk: [ !PQ_Esk(A, esk) ] --[ PqEskReveal(A) ]-> [ Out(esk) ] rule Reveal_npq_esk: [ !NPQ_Esk(A, esk) ] --[ NpqEskReveal(A) ]-> [ Out(esk) ] /* 0-RTT Handshake */ rule 0RTT_PQConnectI: let c0 = aenc(~k0, spkRmceliece) CI = ~k0 HI = c0 c1 = aeadenc(CI,'0','g'^~eskIx25519,HI) HI = h() k1 = spkRx25519^~eskIx25519 CI = kdf() k2 = epkRx25519^~eskIx25519 CI = kdf() c2 = aenc(~k3, epkRsntrup) c3 = aeadenc(CI,'0',c2,HI) CI = kdf() HI = h() tid = kdf() TI = kdf2() TR = kdf3() in [ !PQ_Spk(R,spkRmceliece), !NPQ_Spk(R,spkRx25519), !PQ_Epk(R,epkRsntrup), !NPQ_Epk(R,epkRx25519), Fr(~eskIx25519), Fr(~k0), Fr(~k3)] --[Zero_RTT(tid), InitiatorTunnel(R,tid,TI,TR)]-> [ Out(<'1',c0,c1,c3>) ] rule 0RTT_PQConnectR: let k0 = adec(c0,~sskRmceliece) CR = k0 HR = c0 epkIx25519 = aeaddec(CR,'0',c1,HR) HR = h() k1 = epkIx25519^~sskRx25519 CR = kdf() k2 = epkIx25519^~eskRx25519 CR = kdf() c2 = aeaddec(CR,'0',c3,HR) k3 = adec(c2,~eskRsntrup) CR = kdf() HR = h() tid = kdf() TI = kdf2() TR = kdf3() in [ !PQ_Ssk($R,~sskRmceliece), !NPQ_Ssk($R,~sskRx25519), !PQ_Esk($R,~eskRsntrup), !NPQ_Esk($R,~eskRx25519), In(<'1',c0,c1,c3>) ] --[Secret(tid), Secret(TR), Secret(TI), ResponderTunnel($R,tid,TR,TI)]-> [] /* lemmas */ lemma 0_RTT_executable: /* There exists a trace, such that */ exists-trace /* There exists a responder R, tunnelID id, transport keys ti */ /* and tr, and times #i and #j*/ " Ex R id ti tr #i #j. /* Such that the 0-RTT handshake finished for id at time #i */ Zero_RTT(id) @ #i /* The initiator established a tunnel with R at time #i*/ & InitiatorTunnel(R,id,ti,tr) @ #i /* and the R established a tunnel with the same */ /* tunnelID ad transport keys at time #j*/ & ResponderTunnel(R,id,tr,ti) @ #j " lemma 0_RTT_FS_confidential: /* For all handshakes occuring at time i */ " All S id ti tr #i #j #k. ( InitiatorTunnel(S,id,ti,tr) @ #i /* if long term key compromise occurs after time i */ & NpqSskReveal(S) @ #j & (i < j) & PqSskReveal(S) @ #k & (i < k) /* and there is never also a compromise of the server's Post-Quantum ephemeral keys */ & not(Ex #l. PqEskReveal(S) @ #l ) ) ==> /* then at no time does an adversary learn ti or tr */ ( not(Ex #r. K(ti) @ #r) ¬(Ex #s. K(tr) @ #s) ) " lemma responder_client_auth: /* For all Servers R and S and shared values tid,ti,tr, If a client has created a tunnel with R, and S has created a tunnel with the same values, then S must be R*/ " All R S id ti tr #i #j. InitiatorTunnel(R,id,ti,tr) @ i & ResponderTunnel(S,id,ti,tr) @ j ==> S = R " end pqconnect-1.2.1/pyproject.toml0000644000000000000000000000422414733452565015137 0ustar rootroot[project] name = "pqconnect" version = "1.2.1" description = "PQConnect Post-Quantum Boring Private Network" readme = "README.md" requires-python = ">=3.7" license = {file = "LICENSE"} keywords = ["post-quantum", "cryptography", "VPN", "BPN", "tunnel"] authors = [ {name = "Jonathan Levin", email = "pqconnect@riseup.net" } ] classifiers = [ "Development Status :: 4 - Beta", "Topic :: Security :: Cryptography", "Topic :: Security" ] dependencies = [ "click", "dnspython", "pyroute2", "py25519 @ git+https://www.github.com/ondesmartenot/py25519@20241202", "pysodium", "pymceliece", "pyntruprime", "nftables @ git+https://salsa.debian.org/pkg-netfilter-team/pkg-nftables@52644ab690c2862c9575e3ca0ce58504a62839de#subdirectory=py", "netfilterqueue", "scapy", "SecureString", ] [project.scripts] pqconnect = "pqconnect.client:main" pqconnect-dns-query = "pqconnect.util:dns_query_main" pqconnect-server = "pqconnect.server:main" pqconnect-keygen = "pqconnect.keygen:main" [build-system] build-backend = "flit_core.buildapi" requires = ["flit_core >=3.8.0,<4", "build", "wheel"] [project.optional-dependencies] dev = [ "isort", "black", "pyflakes", "pylint", "flake8", "ruff", "tryceratops", "mypy", "pyright", "pyre-check", "coverage", "vulture" ] [tool.black] line-length = 79 [tool.mypy] # Disallow untyped definitions and calls # disallow_untyped_calls = "False" disallow_untyped_defs = "True" disallow_incomplete_defs = "True" check_untyped_defs = "True" disallow_untyped_decorators = "True" # None and optional handling no_implicit_optional = "True" # Configuring warnings warn_unused_ignores = "True" warn_no_return = "True" # warn_return_any = "True" warn_redundant_casts = "True" # Misc things strict_equality = "True" # Config file warn_unused_configs = "True" ignore_missing_imports = "True" [tool.pyright] venvPath="." venv="venv" include = ["src"] exclude = ["**/__pycache__"] # ignore = [] defineConstant = { DEBUG = true } stubPath = "out" reportMissingImports = true reportMissingTypeStubs = false pythonVersion = "3.6" pythonPlatform = "Linux" executionEnvironments = [ { root = "src" } ] pqconnect-1.2.1/scripts/0000755000000000000000000000000014733452565013710 5ustar rootrootpqconnect-1.2.1/scripts/change-server-cryptoport0000755000000000000000000000032314733452565020610 0ustar rootroot#!/bin/sh umask 077 port=${1-42424} echo "$port" > /etc/pqconnect/config/pqcport . run/bin/activate export LD_LIBRARY_PATH=/usr/local/lib pqconnect-keygen -c /etc/pqconnect/config -d /etc/pqconnect/keys -D pqconnect-1.2.1/scripts/change-server-keyport0000755000000000000000000000032314733452565020060 0ustar rootroot#!/bin/sh umask 077 port=${1-42425} echo "$port" > /etc/pqconnect/config/keyport . run/bin/activate export LD_LIBRARY_PATH=/usr/local/lib pqconnect-keygen -c /etc/pqconnect/config -d /etc/pqconnect/keys -D pqconnect-1.2.1/scripts/create-first-server-key0000755000000000000000000000100514733452565020314 0ustar rootroot#!/bin/sh umask 077 if [ -d /etc/pqconnect/config ] then echo /etc/pqconnect/config already exists, not touching that else mkdir -p /etc/pqconnect/config echo 42424 > /etc/pqconnect/config/pqcport echo 42425 > /etc/pqconnect/config/keyport fi if [ -d /etc/pqconnect/keys ] then echo /etc/pqconnect/keys already exists, not touching that else . run/bin/activate export LD_LIBRARY_PATH=/usr/local/lib mkdir -p /etc/pqconnect/keys pqconnect-keygen -c /etc/pqconnect/config -d /etc/pqconnect/keys fi pqconnect-1.2.1/scripts/download-build-install-deps0000755000000000000000000001321114733452565021135 0ustar rootroot#!/bin/bash set -eux nproc=`nproc` if [ -d downloads ]; then ls -alh downloads; else mkdir downloads; fi cd downloads DESTDIR=/usr/local if lsb_release &>/dev/null; then dist=$(lsb_release -a | grep Distributor | awk {'print $3'}) else dist="Debian" fi case $dist in Ubuntu | Debian) sudo env NEEDRESTART_SUSPEND=1 \ apt install nftables \ libnetfilter-queue-dev \ libsodium-dev \ build-essential \ autoconf \ python3 \ python3-virtualenv \ python3-dev \ python3-build \ wget \ curl \ git \ libnfnetlink-dev \ python3-nftables \ libssl-dev \ -y sudo env NEEDRESTART_SUSPEND=1 \ apt install python3-capstone -y || : sudo env NEEDRESTART_SUSPEND=1 \ apt install lib25519-dev -y || : sudo env NEEDRESTART_SUSPEND=1 \ apt install libmceliece-dev -y || : sudo env NEEDRESTART_SUSPEND=1 \ apt install libntruprime-dev -y || : ;; Arch) sudo pacman \ --noconfirm \ -S nftables \ wget \ curl \ git \ libnetfilter_queue \ libsodium \ base-devel \ autoconf \ python \ python-virtualenv \ libnfnetlink sudo pacman \ --noconfirm \ -S python-capstone || : ;; Gentoo) sudo emerge -vn net-firewall/nftables \ net-libs/libnetfilter_queue \ dev-libs/libsodium \ dev-lang/python \ dev-python/pip \ dev-python/virtualenv \ dev-build/autoconf \ net-libs/libnfnetlink \ net-misc/wget \ net-misc/curl \ dev-libs/openssl \ dev-vcs/git ;; esac install_libcpucycles() { # Lifted from https://cpucycles.cr.yp.to/download.html and # https://cpucycles.cr.yp.to/install.html wget -m https://cpucycles.cr.yp.to/libcpucycles-latest-version.txt cpucycles_version=$(cat cpucycles.cr.yp.to/libcpucycles-latest-version.txt) wget -m https://cpucycles.cr.yp.to/libcpucycles-$cpucycles_version.tar.gz tar -xzf cpucycles.cr.yp.to/libcpucycles-$cpucycles_version.tar.gz cd libcpucycles-$cpucycles_version \ && ./configure --prefix="$DESTDIR" \ && sudo make -j$nproc install cd .. } install_librandombytes() { wget -m https://randombytes.cr.yp.to/librandombytes-latest-version.txt randombytes_version=$(cat randombytes.cr.yp.to/librandombytes-latest-version.txt) wget -m https://randombytes.cr.yp.to/librandombytes-$randombytes_version.tar.gz tar -xzf randombytes.cr.yp.to/librandombytes-$randombytes_version.tar.gz cd librandombytes-$randombytes_version \ && ./configure --prefix="$DESTDIR" \ && sudo make -j$nproc install cd .. } install_lib25519() { wget -m https://lib25519.cr.yp.to/lib25519-latest-version.txt lib25519_version=$(cat lib25519.cr.yp.to/lib25519-latest-version.txt) wget -m https://lib25519.cr.yp.to/lib25519-$lib25519_version.tar.gz tar -xzf lib25519.cr.yp.to/lib25519-$lib25519_version.tar.gz command -v valgrind && VALGRIND="--valgrind" || VALGRIND="--no-valgrind" cd lib25519-$lib25519_version \ && ./configure --prefix="$DESTDIR" $VALGRIND \ && sudo make -j$nproc install cd .. } install_libmceliece() { wget -m https://lib.mceliece.org/libmceliece-latest-version.txt libmceliece_version=$(cat lib.mceliece.org/libmceliece-latest-version.txt) wget -m https://lib.mceliece.org/libmceliece-$libmceliece_version.tar.gz tar -xzf lib.mceliece.org/libmceliece-$libmceliece_version.tar.gz command -v valgrind && VALGRIND="--valgrind" || VALGRIND="--no-valgrind" cd libmceliece-$libmceliece_version \ && ./configure --prefix="$DESTDIR" $VALGRIND \ && sudo make -j$nproc install cd .. } install_libntruprime() { wget -m https://libntruprime.cr.yp.to/libntruprime-latest-version.txt libntruprime_version=$(cat libntruprime.cr.yp.to/libntruprime-latest-version.txt) wget -m https://libntruprime.cr.yp.to/libntruprime-$libntruprime_version.tar.gz tar -xzf libntruprime.cr.yp.to/libntruprime-$libntruprime_version.tar.gz command -v valgrind && VALGRIND="--valgrind" || VALGRIND="--no-valgrind" cd libntruprime-$libntruprime_version \ && ./configure --prefix="$DESTDIR" $VALGRIND \ && sudo make -j$nproc install cd .. } gcc -lcpucycles 2>&1 | grep main > /dev/null || install_libcpucycles gcc -lrandombytes 2>&1 | grep main > /dev/null || install_librandombytes gcc -l25519 2>&1 | grep main > /dev/null || install_lib25519 gcc -lmceliece 2>&1 | grep main > /dev/null || install_libmceliece gcc -lntruprime 2>&1 | grep main > /dev/null || install_libntruprime exit 0 pqconnect-1.2.1/scripts/external.py0000644000000000000000000000054714733452565016112 0ustar rootrootimport os import sysconfig if __name__ == "__main__": try: lib_path = sysconfig.get_path("stdlib", sysconfig.get_default_scheme()) except AttributeError: lib_path = sysconfig.get_path("stdlib") if os.path.isfile(os.path.join(lib_path, "EXTERNALLY-MANAGED")): print("--break-system-packages") else: print("") pqconnect-1.2.1/scripts/install-pqconnect0000755000000000000000000000035414733452565017276 0ustar rootroot#!/bin/sh umask 077 scripts/download-build-install-deps make [ -d /etc/pqconnect ] || ( mkdir /etc/pqconnect useradd -M -d /etc/pqconnect -s /bin/false pqconnect ) [ -d run ] || virtualenv run . run/bin/activate pip install . pqconnect-1.2.1/scripts/install-tamarin0000755000000000000000000001236314733452565016742 0ustar rootroot#!/bin/sh # tamarin relies on ghc, haskell-stack, etc. # which support x86_64 and aarch64: # https://www.haskell.org/ghc/download_ghc_9_10_1.html#binaries # https://github.com/commercialhaskell/stack/releases # on x86_64: # easy to install haskell-stack and tamarin via homebrew # using "linuxbrew" account so that homebrew avoids compiling from source # on aarch64: # homebrew tries compiling far too many packages, # often packages that need patching, so take a different route umask 022 architecture=`uname -m` [ -d /home/tamarin ] || useradd -m -s /bin/sh tamarin sudo -u tamarin sh -c 'cd cat > pqconnect_handshake.spthy ' < handshake.spthy sudo -u tamarin sh -c 'cd mkdir -p lib include bin tmp ( echo "#X/bin/sh" | tr X "\\041" echo "ulimit -n 2048" echo "ulimit -n 4096" echo "ulimit -n 8192" echo "export LD_LIBRARY_PATH=\"\$HOME/lib\"" echo "export LIBRARY_PATH=\"\$HOME/lib\"" echo "export CPATH=\"\$HOME/include\"" echo "export PATH=\"\$HOME/bin:\$HOME/.local/bin:\$PATH\"" ) > tmp/tamarin ' if [ "$architecture" = x86_64 ] then [ -d /home/linuxbrew ] || useradd -m -s /bin/sh linuxbrew sudo -u linuxbrew sh -c 'cd chmod 755 $HOME ulimit -n 2048 ulimit -n 4096 ulimit -n 8192 export HOMEBREW_NO_AUTO_UPDATE=1 [ -d .linuxbrew ] || time git clone https://github.com/Homebrew/brew .linuxbrew eval "$(.linuxbrew/bin/brew shellenv)" time brew update --force chmod -R go-w "$(brew --prefix)/share/zsh" time brew install tamarin-prover/tap/tamarin-prover ' sudo -u tamarin sh -c 'cd /home/linuxbrew/.linuxbrew/bin/brew shellenv >> tmp/tamarin ' elif [ "$architecture" = aarch64 ] then sudo -u tamarin sh -c 'cd ulimit -n 2048 ulimit -n 4096 ulimit -n 8192 export LD_LIBRARY_PATH="$HOME/lib" export LIBRARY_PATH="$HOME/lib" export CPATH="$HOME/include" export PATH="$HOME/bin:$HOME/.local/bin:$PATH" architecture=`uname -m` stack="stack-linux-${architecture}" wget -O "$stack.tar.gz" "https://www.stackage.org/stack/linux-${architecture}" tar -xf "$stack.tar.gz" rm -f $HOME/bin/stack ln -s $HOME/stack-*-linux-"${architecture}/stack" $HOME/bin/stack [ -d tamarin-prover ] || ( cd tmp rm -rf tamarin-prover git clone https://github.com/tamarin-prover/tamarin-prover.git mv tamarin-prover $HOME ) ( cd tamarin-prover make default ) # maude installation mostly cribbed from https://github.com/maude-lang/Maude/blob/master/INSTALL # but a bit more effort here at idempotence etc. ( echo libsigsegv-2.13.tar.gz https://ftp.gnu.org/gnu/libsigsegv/libsigsegv-2.13.tar.gz echo gmp-6.3.0.tar.xz https://gmplib.org/download/gmp/gmp-6.3.0.tar.xz echo buddy-2.4.tar.gz https://github.com/utwente-fmt/buddy/releases/download/v2.4/buddy-2.4.tar.gz echo libtecla-1.6.3.tar.gz https://sites.astro.caltech.edu/~mcs/tecla/libtecla-1.6.3.tar.gz echo Yices-2.6.4.tar.gz https://github.com/SRI-CSL/yices2/archive/refs/tags/Yices-2.6.4.tar.gz echo gperf-3.1.tar.gz https://ftp.gnu.org/pub/gnu/gperf/gperf-3.1.tar.gz echo Maude3.5.tar.gz https://github.com/maude-lang/Maude/archive/refs/tags/Maude3.5.tar.gz ) | while read target url do [ -f $target ] || ( wget -O $target.tmp "$url" && mv $target.tmp $target ) tar -xf $target done ( cd libsigsegv-2.13 ./configure CFLAGS="-g -fno-stack-protector -O3" --prefix=$HOME --enable-shared=no make -j`nproc` # make check make install ) ( cd gmp-6.3.0 ./configure --prefix=$HOME --enable-cxx --enable-fat --enable-shared=yes --build=${architecture}-pc-linux-gnu make -j`nproc` # make check make install ) ( cd buddy-2.4 ./configure LDFLAGS=-lm CFLAGS="-g -fno-stack-protector -O3" CXXFLAGS="-g -fno-stack-protector -O3" --prefix=$HOME --disable-shared make -j`nproc` # make check make install ) ( cd gperf-3.1 ./configure --prefix=$HOME make make install ) ( cd libtecla autoupdate autoreconf -i cp /usr/share/automake-*/config.sub . cp /usr/share/automake-*/config.guess . ./configure CFLAGS="-g -fno-stack-protector -O3" --prefix=$HOME make make install ) ( cd yices2-Yices-2.6.4 autoconf ./configure --prefix=$HOME \ --with-static-gmp=$HOME/lib/libgmp.a --with-static-gmp-include-dir=$HOME/include \ CFLAGS="-g -fno-stack-protector -O3" LDFLAGS="-L$HOME/lib" CPPFLAGS="-I$HOME/include" make -j`nproc` # make check make install ) ( cd Maude-Maude3.5 autoreconf -i mkdir Opt cd Opt ../configure --with-yices2=yes --with-cvc4=no --enable-compiler \ CXXFLAGS="-g -Wall -O3 -fno-stack-protector" CPPFLAGS="-I$HOME/include" LDFLAGS="-L$HOME/lib" \ GMP_LIBS="$HOME/lib/libgmpxx.a $HOME/lib/libgmp.a" make -j`nproc` # make check cp src/Main/maude $HOME/bin/maude cp ../src/Main/prelude.maude $HOME/bin/prelude.maude ) ' else echo "This script supports only x86_64 and aarch64." fi sudo -u tamarin sh -c 'cd echo "exec tamarin-prover \"\$@\"" >> tmp/tamarin chmod 755 tmp/tamarin mv tmp/tamarin tamarin ' pqconnect-1.2.1/scripts/pqconnect-client.service0000644000000000000000000000222514733452565020541 0ustar rootroot[Unit] Description=PQConnect client After=network.target [Service] Type=simple UMask=077 StandardInput=null StandardOutput=journal StandardError=journal WorkingDirectory=/root/pqconnect ExecStart=scripts/run-client-core TimeoutStartSec=infinity Restart=always RestartSec=1s CapabilityBoundingSet=CAP_NET_ADMIN CAP_SETUID CAP_SETGID CAP_KILL DevicePolicy=strict DeviceAllow=/dev/net/tun rwm DeviceAllow=/dev/null rw DeviceAllow=/dev/urandom r LockPersonality=yes MemoryMax=2G NoNewPrivileges=yes PrivateTmp=yes ProtectClock=yes ProtectControlGroups=yes ProtectHostname=yes ProtectKernelLogs=yes ProtectKernelModules=yes ProtectKernelTunables=yes ProtectProc=ptraceable ProtectSystem=yes ReadOnlyPaths=/ ReadWritePaths=/run RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK RestrictNamespaces=yes RestrictRealtime=yes RestrictSUIDSGID=yes StateDirectory=pqconnect SystemCallArchitectures=native SystemCallFilter=~@clock SystemCallFilter=~@cpu-emulation SystemCallFilter=~@debug SystemCallFilter=~@module SystemCallFilter=~@mount SystemCallFilter=~@obsolete SystemCallFilter=~@raw-io SystemCallFilter=~@reboot SystemCallFilter=~@swap [Install] WantedBy=multi-user.target pqconnect-1.2.1/scripts/pqconnect-server.service0000644000000000000000000000217514733452565020575 0ustar rootroot[Unit] Description=PQConnect server After=network.target [Service] Type=simple UMask=077 StandardInput=null StandardOutput=journal StandardError=journal WorkingDirectory=/root/pqconnect ExecStart=scripts/run-server-core TimeoutStartSec=infinity Restart=always RestartSec=1s CapabilityBoundingSet=CAP_NET_ADMIN CAP_SETUID CAP_SETGID CAP_KILL CAP_NET_BIND_SERVICE DevicePolicy=strict DeviceAllow=/dev/net/tun rwm DeviceAllow=/dev/null rw DeviceAllow=/dev/urandom r LockPersonality=yes MemoryMax=2G NoNewPrivileges=yes PrivateTmp=yes ProtectClock=yes ProtectControlGroups=yes ProtectHostname=yes ProtectKernelLogs=yes ProtectKernelModules=yes ProtectKernelTunables=yes ProtectProc=ptraceable ProtectSystem=yes ReadOnlyPaths=/ RestrictAddressFamilies=AF_INET AF_INET6 AF_NETLINK RestrictNamespaces=yes RestrictRealtime=yes RestrictSUIDSGID=yes SystemCallArchitectures=native SystemCallFilter=~@clock SystemCallFilter=~@cpu-emulation SystemCallFilter=~@debug SystemCallFilter=~@module SystemCallFilter=~@mount SystemCallFilter=~@obsolete SystemCallFilter=~@raw-io SystemCallFilter=~@reboot SystemCallFilter=~@swap [Install] WantedBy=multi-user.target pqconnect-1.2.1/scripts/run-client0000755000000000000000000000007614733452565015721 0ustar rootroot#!/bin/sh exec scripts/run-client-core "$@" >> pqconnect-log pqconnect-1.2.1/scripts/run-client-core0000755000000000000000000000015114733452565016641 0ustar rootroot#!/bin/sh umask 077 . run/bin/activate export LD_LIBRARY_PATH=/usr/local/lib exec pqconnect "$@" 2>&1 pqconnect-1.2.1/scripts/run-client-verbose0000755000000000000000000000010214733452565017352 0ustar rootroot#!/bin/sh exec scripts/run-client-core -vv "$@" >> pqconnect-log pqconnect-1.2.1/scripts/run-server0000755000000000000000000000007614733452565015751 0ustar rootroot#!/bin/sh exec scripts/run-server-core "$@" >> pqconnect-log pqconnect-1.2.1/scripts/run-server-core0000755000000000000000000000065614733452565016703 0ustar rootroot#!/bin/sh umask 077 . run/bin/activate export LD_LIBRARY_PATH=/usr/local/lib export KEYPATH=/etc/pqconnect/keys pqcport=`cat /etc/pqconnect/config/pqcport` keyport=`cat /etc/pqconnect/config/keyport` if [ -f /etc/pqconnect/config/host ] then host=`cat /etc/pqconnect/config/host` exec pqconnect-server -H "$host" -p "$pqcport" -k "$keyport" "$@" 2>&1 else exec pqconnect-server -p "$pqcport" -k "$keyport" "$@" 2>&1 fi pqconnect-1.2.1/scripts/run-server-verbose0000755000000000000000000000010214733452565017402 0ustar rootroot#!/bin/sh exec scripts/run-server-core -vv "$@" >> pqconnect-log pqconnect-1.2.1/scripts/run-tamarin0000755000000000000000000000012314733452565016067 0ustar rootroot#!/bin/sh sudo -u tamarin sh -c 'cd; ./tamarin --prove pqconnect_handshake.spthy' pqconnect-1.2.1/scripts/start-client-under-daemontools0000755000000000000000000000132214733452565021702 0ustar rootroot#!/bin/sh if [ -e /service/pqconnect-client ] then : # presumably everything ok already else scripts/start-daemontools useradd -M -d /etc/pqconnect -s /bin/false pqconnectlog 2>/dev/null mkdir -p services/client/log/main chmod 700 services ( /bin/echo '#!/bin/sh' /bin/echo 'cd '`pwd` /bin/echo 'scripts/run-client-core' /bin/echo 'sleep 1' ) > services/client/run chmod 755 services/client/run ( /bin/echo '#!/bin/sh' /bin/echo 'exec setuidgid pqconnectlog multilog t n10 s100000 ./main' ) > services/client/log/run chmod 755 services/client/log/run chown pqconnectlog services/client/log/main ln -s `pwd`/services/client /service/pqconnect-client fi pqconnect-1.2.1/scripts/start-client-under-systemd0000755000000000000000000000065114733452565021052 0ustar rootroot#!/bin/sh sc=client service=pqconnect-$sc fn=/etc/systemd/system/$service.service awk -v sc=$sc -v pwd=`pwd` '{ if ($1 == "WorkingDirectory=/root/pqconnect") $0 = "WorkingDirectory="pwd if ($1 == "ExecStart=scripts/run-"sc"-core") $0 = "ExecStart="pwd"/scripts/run-"sc"-core" print }' < scripts/$service.service > ${fn}.tmp chmod 644 ${fn}.tmp mv ${fn}.tmp $fn systemctl daemon-reload systemctl start $service pqconnect-1.2.1/scripts/start-daemontools0000755000000000000000000000311514733452565017315 0ustar rootroot#!/bin/sh if [ -e /service ] then : # presumably everything ok already else if lsb_release &>/dev/null; then dist=$(lsb_release -a | grep Distributor | awk {'print $3'}) else dist="Debian" fi case $dist in Ubuntu | Debian) [ -e /etc/service ] || apt install daemontools-run -y ln -s /etc/service /service ;; *) # assuming compiler tools installed from download-build-install-deps # assuming systemd-based system for starting daemontools mkdir -p /package chmod 1755 /package cd /package wget https://cr.yp.to/daemontools/daemontools-0.76.tar.gz gunzip daemontools-0.76.tar tar -xpf daemontools-0.76.tar cd admin/daemontools-0.76 echo gcc -O \ --include=errno.h \ --include=unistd.h \ --include=grp.h \ --include=signal.h \ --include=stdio.h \ -Wno-incompatible-pointer-types \ -Wno-implicit-int \ > src/conf-cc package/compile package/upgrade mkdir -p /service ( echo '[Unit]' echo 'Description=DJB daemontools' echo 'After=sysinit.target' echo '' echo '[Service]' echo 'ExecStart=/command/svscanboot' echo 'Restart=always' echo '' echo '[Install]' echo 'WantedBy=multi-user.target' ) > /lib/systemd/system/daemontools.service chmod 644 /lib/systemd/system/daemontools.service ln -s /lib/systemd/system/daemontools.service /etc/systemd/system/multi-user.target.wants/ systemctl start daemontools esac fi pqconnect-1.2.1/scripts/start-server-under-daemontools0000755000000000000000000000132114733452565021731 0ustar rootroot#!/bin/sh if [ -e /service/pqconnect-server ] then : # presumably everything ok already else scripts/start-daemontools useradd -M -d /etc/pqconnect -s /bin/false pqconnectlog 2>/dev/null mkdir -p services/server/log/main chmod 700 services ( /bin/echo '#!/bin/sh' /bin/echo 'cd '`pwd` /bin/echo 'scripts/run-server-core' /bin/echo 'sleep 1' ) > services/server/run chmod 755 services/server/run ( /bin/echo '#!/bin/sh' /bin/echo 'exec setuidgid pqconnectlog multilog t n10 s1000000 ./main' ) > services/server/log/run chmod 755 services/server/log/run chown pqconnectlog services/server/log/main ln -s `pwd`/services/server /service/pqconnect-server fi pqconnect-1.2.1/scripts/start-server-under-systemd0000755000000000000000000000065114733452565021102 0ustar rootroot#!/bin/sh sc=server service=pqconnect-$sc fn=/etc/systemd/system/$service.service awk -v sc=$sc -v pwd=`pwd` '{ if ($1 == "WorkingDirectory=/root/pqconnect") $0 = "WorkingDirectory="pwd if ($1 == "ExecStart=scripts/run-"sc"-core") $0 = "ExecStart="pwd"/scripts/run-"sc"-core" print }' < scripts/$service.service > ${fn}.tmp chmod 644 ${fn}.tmp mv ${fn}.tmp $fn systemctl daemon-reload systemctl start $service pqconnect-1.2.1/server.patch0000644000000000000000000000025314733452565014550 0ustar rootroot25,26d24 < "nftables @ git+https://salsa.debian.org/pkg-netfilter-team/pkg-nftables@52644ab690c2862c9575e3ca0ce58504a62839de#subdirectory=py", < "netfilterqueue", pqconnect-1.2.1/src/0000755000000000000000000000000014733452565013010 5ustar rootrootpqconnect-1.2.1/src/pqconnect/0000755000000000000000000000000014733452565015002 5ustar rootrootpqconnect-1.2.1/src/pqconnect/__init__.py0000644000000000000000000000000014733452565017101 0ustar rootrootpqconnect-1.2.1/src/pqconnect/client.py0000644000000000000000000001564714733452565016647 0ustar rootrootfrom multiprocessing import Event, Pipe, Process, active_children from multiprocessing.connection import Connection from multiprocessing.synchronize import Event as Ev from os import getpid, getuid, kill, remove from os.path import exists from pwd import getpwnam from signal import SIG_IGN, SIGINT, SIGTERM, SIGUSR1, signal from socket import inet_aton from sys import exit as bye from time import sleep from types import FrameType from typing import Dict, Optional import click from pqconnect.common.constants import ( IP_CLIENT, PIDCLIENTPATH, PIDPATH, PQCPORT_CLIENT, PRIVSEP_USER, ) from pqconnect.common.util import display_version, run_as_user from pqconnect.dnsproxy import DNSNetfilterProxy from pqconnect.iface import create_tun_interface, tun_listen from pqconnect.log import logger from pqconnect.pqcclient import PQCClient def send_usr1_to_client(client_pid_path: str = PIDCLIENTPATH) -> None: """Opens pid file for client and sends a SIGUSR1 signal to that pid""" try: with open(client_pid_path, "r") as f: pid = int(f.read().strip()) kill(pid, SIGUSR1) except FileNotFoundError: print("\x1b[33;93mError:\x1b[0m Is PQConnect running?") @run_as_user(PRIVSEP_USER) def run_client( port: int, tun_conn: Connection, dns_conn: Connection, event: Ev, dev_name: str, host_ip: Optional[str], ) -> None: """Runs the main client process as an unprivileged user""" try: # Initialize client (privsep) client = PQCClient( port, tun_conn, dns_conn, event, dev_name=dev_name, host_ip=host_ip ) except Exception as e: logger.exception(f"Could not initialize client: {e}") bye(1) client.start() @click.command() @click.option("--version", is_flag=True, help="Display version") @click.option( "-p", "--port", type=click.IntRange(0, 65535), default=PQCPORT_CLIENT, help="UDP listening port", ) @click.option( "-a", "--addr", type=click.STRING, default=IP_CLIENT, help="local IPv4 address", ) @click.option( "-m", "--mask", type=click.IntRange(8, 24), default=16, help="netmask for private network", ) @click.option( "-i", "--interface-name", default="pqccli0", help="PQConnect network interface name", ) @click.option("-v", "--verbose", is_flag=True, help="enable verbose logging") @click.option( "-vv", "--very-verbose", is_flag=True, help="enable even more verbose logging", ) @click.option("--show", is_flag=True, help="show active PQConnect connections") @click.option( "-H", "--host-ip", type=click.STRING, help="IP address where decrypted traffic should arrive (required if PQConnect is running on a VM, for example)", ) def main( version: bool, port: int, addr: str, mask: int, interface_name: str, verbose: bool, very_verbose: bool, show: bool, host_ip: Optional[str], ) -> None: if version: display_version() bye() if show: send_usr1_to_client(PIDCLIENTPATH) bye() if getuid() != 0: logger.error("PQConnect must be run as root") bye(1) if exists(PIDPATH): try: with open(PIDPATH, "r") as f: pid = int(f.read().strip()) kill(pid, 0) print("\x1b[33;93mError:\x1b[0m PQConnect is already running.") bye(1) except Exception: # .pid file is stale. Delete and continue remove(PIDPATH) try: getpwnam(PRIVSEP_USER) except KeyError: logger.exception( f"User {PRIVSEP_USER} does not exist." " Please create it before starting PQConnect" ) bye(1) try: inet_aton(addr) except OSError: logger.exception(f"Invalid IPv4 address: {addr}") bye(1) if verbose: logger.setLevel(10) elif very_verbose: logger.setLevel(9) # create TUN device try: tun_file = create_tun_interface(interface_name, addr, mask) except PermissionError: logger.exception("Operation not permitted") bye(1) except Exception as e: logger.exception(e) bye(1) logger.info("Starting PQConnect") child_sig = Event() children: Dict[Optional[int], str] = {} # socket to pass packets to/from tun device tun_conn0, tun_conn1 = Pipe() # Pipe to pass packets to/from DNS proxy dns_conn0, dns_conn1 = Pipe() try: # DNS proxy (root) dns_proxy = DNSNetfilterProxy(dns_conn0) except Exception as e: logger.exception(f"Could not initialize DNS Proxy: {e}") bye(1) # Client process (pqconnect) cli_proc = Process( target=run_client, args=(port, tun_conn1, dns_conn1, child_sig, interface_name, host_ip), ) # TUN relay (root) tun_proc = Process( target=tun_listen, args=(tun_file, tun_conn0, child_sig), ) # DNS proxy (root) dns_proc = Process(target=dns_proxy.run) def graceful_shutdown(signum: int, frame: Optional[FrameType]) -> None: """Gracefully shut down child processes""" child_sig.set() if signum == SIGINT: bye(0) if signum == SIGTERM: bye(SIGTERM) def set_signal_handlers() -> None: """Sets signal handlers for the main process SIGTERM and SIGINT should both trigger graceful shutdown SIGUSR1 should be ignored (terminates process by default), as we will use it in the client child process """ # set Signal handlers signal(SIGTERM, graceful_shutdown) signal(SIGINT, graceful_shutdown) signal(SIGUSR1, SIG_IGN) set_signal_handlers() # Run try: logger.info("Starting DNS Proxy...") dns_proc.start() children[dns_proc.pid] = "DNS Proxy" logger.info("Starting TUN listener") tun_proc.start() children[tun_proc.pid] = "TUN Process" logger.info("Starting client") cli_proc.start() children[cli_proc.pid] = "Client Process" with open(PIDPATH, "w") as f: f.write(str(getpid())) with open(PIDCLIENTPATH, "w") as f: f.write(str(cli_proc.pid)) dns_proc.join() tun_proc.join() cli_proc.join() finally: logger.info("Exiting...") dns_conn0.close() tun_file.close() tun_conn0.close() tun_conn1.close() dns_conn1.close() # Children should have stopped. Log any stragglers and then kill them. sleep(1) for p in active_children(): if p.is_alive(): logger.error( f"{children[p.pid]} did not terminate. Killing..." ) p.terminate() remove(PIDPATH) remove(PIDCLIENTPATH) if __name__ == "__main__": try: main() except Exception: bye(1) pqconnect-1.2.1/src/pqconnect/common/0000755000000000000000000000000014733452565016272 5ustar rootrootpqconnect-1.2.1/src/pqconnect/common/__init__.py0000644000000000000000000000000014733452565020371 0ustar rootrootpqconnect-1.2.1/src/pqconnect/common/constants.py0000644000000000000000000000406214733452565020662 0ustar rootrootfrom os import environ from os.path import join from struct import pack SUPPORTED_MAJOR_VERSIONS = ("1",) DEFAULT_KEYPATH = "/etc/pqconnect/keys" CONFIG_PATH = "/etc/pqconnect/config" # Check if user passed another keypath in as environment variable try: DEFAULT_KEYPATH = environ["KEYPATH"] except KeyError: pass PRIVSEP_USER = "pqconnect" # ===netfilterqueue=== # Number of different queue values to try when adding a netfilter queue rule NUM_QUEUE_ATTEMPTS = 10 # ===DNS=== A_RECORD = 1 DNS_ENCODED_HASH_LEN = 52 DNS_ENCODED_PORT_LEN = 4 # ===Port settings=== PQCPORT_CLIENT = 42423 PQCPORT = 42424 KEYPORT = 42425 # ===Default private IP addresses=== IP_SERVER = "10.42.0.1" IP_CLIENT = "10.43.0.1" # ===Key Settings=== MCELIECE_SK_PATH = join(DEFAULT_KEYPATH, "mceliece_sk") MCELIECE_PK_PATH = join(DEFAULT_KEYPATH, "mceliece_pk") X25519_SK_PATH = join(DEFAULT_KEYPATH, "x25519_sk") X25519_PK_PATH = join(DEFAULT_KEYPATH, "x25519_pk") SESSION_KEY_PATH = join(DEFAULT_KEYPATH, "session_key") SEG_LEN = 1152 # ===Misc constants=== MAGIC_NUMBER = b"pq1" # Precedes a pk hash EPOCH_DURATION_SECONDS = 30 EPOCH_TIMEOUT_SECONDS = 120 MAX_CONNS = 1 << 16 TIDLEN = 32 DAY_SECONDS = 60 * 60 * 24 # https://bench.cr.yp.to/results-stream.html#amd64-samba CHAIN_KEY_NUM_PACKETS = 18 MAX_CHAIN_LEN = 5 * CHAIN_KEY_NUM_PACKETS MAX_EPOCHS = 5 # The client will need to download the server's long-term keys at *most* twice # per day NUM_PREKEYS = 60 * 60 * 12 // EPOCH_TIMEOUT_SECONDS # ===msg types=== STATIC_KEY_REQUEST = b"\xf0\x00" STATIC_KEY_RESPONSE = b"\xf1\x00" EPHEMERAL_KEY_REQUEST = b"\xf2\x00" EPHEMERAL_KEY_RESPONSE = b"\xf3\x00" HANDSHAKE_FAIL = b"\x03\x00" INITIATION_MSG = b"\x01\x00" TUNNEL_MSG = b"\x02\x00" COOKIE_PREFIX = b"pqccookE" COOKIE_PREFIX_LEN = len(COOKIE_PREFIX) TIMESTAMP_LEN = 8 HDRLEN = len(TUNNEL_MSG) + TIDLEN + len(pack("!HI", 0, 0)) # DNS resolver utility DNS_EXAMPLE_HOST: str = "www.pqconnect.net" # Daemon-related PIDPATH: str = "/run/pqconnect.pid" PIDCLIENTPATH: str = "/run/pqconnect-cli.pid" PIDSERVERPATH: str = "/run/pqconnect-serv.pid" pqconnect-1.2.1/src/pqconnect/common/crypto.py0000644000000000000000000001754214733452565020175 0ustar rootrootfrom hashlib import shake_256 import py25519 from pymceliece import mceliece6960119 from pyntruprime import sntrup761 from pysodium import ( crypto_aead_chacha20poly1305_ietf_ABYTES, crypto_aead_chacha20poly1305_ietf_decrypt_detached, crypto_aead_chacha20poly1305_ietf_encrypt_detached, crypto_aead_chacha20poly1305_ietf_KEYBYTES, crypto_aead_chacha20poly1305_ietf_NONCEBYTES, crypto_stream_chacha20_xor_ic, ) from pysodium import randombytes as rb # crypto settings stream_xor_ic = crypto_stream_chacha20_xor_ic KLEN = crypto_aead_chacha20poly1305_ietf_KEYBYTES NLEN = crypto_aead_chacha20poly1305_ietf_NONCEBYTES skem = mceliece6960119 ekem = sntrup761 TAGLEN = crypto_aead_chacha20poly1305_ietf_ABYTES randombytes = rb HLEN = 32 dh = py25519 def secret_box( key: bytes, nonce: bytes, msg: bytes, ad: bytes = b"" ) -> tuple[bytes, bytes]: """key: 32 byte encryption key nonce: 8 byte nonce msg: bytes to be encrypted and authenticated ad: bytes to be authenticated Described in described in https://tools.ietf.org/html/rfc8439 Section 2.8. AEAD Construction AEAD_CHACHA20_POLY1305 is an authenticated encryption with additional data algorithm. The inputs to AEAD_CHACHA20_POLY1305 are: o A 256-bit key o A 96-bit nonce -- different for each invocation with the same key o An arbitrary length plaintext o Arbitrary length additional authenticated data (AAD) Some protocols may have unique per-invocation inputs that are not 96 bits in length. For example, IPsec may specify a 64-bit nonce. In such a case, it is up to the protocol document to define how to transform the protocol nonce into a 96-bit nonce, for example, by concatenating a constant value. The ChaCha20 and Poly1305 primitives are combined into an AEAD that takes a 256-bit key and 96-bit nonce as follows: o First, a Poly1305 one-time key is generated from the 256-bit key and nonce using the procedure described in Section 2.6. o Next, the ChaCha20 encryption function is called to encrypt the plaintext, using the same key and nonce, and with the initial counter set to 1. o Finally, the Poly1305 function is called with the Poly1305 key calculated above, and a message constructed as a concatenation of the following: * The AAD * padding1 -- the padding is up to 15 zero bytes, and it brings the total length so far to an integral multiple of 16. If the length of the AAD was already an integral multiple of 16 bytes, this field is zero-length. * The ciphertext * padding2 -- the padding is up to 15 zero bytes, and it brings the total length so far to an integral multiple of 16. If the length of the ciphertext was already an integral multiple of 16 bytes, this field is zero-length. * The length of the additional data in octets (as a 64-bit little-endian integer). * The length of the ciphertext in octets (as a 64-bit little- endian integer). The output from the AEAD is the concatenation of: o A ciphertext of the same length as the plaintext. o A 128-bit tag, which is the output of the Poly1305 function. """ return crypto_aead_chacha20poly1305_ietf_encrypt_detached( msg, ad, nonce, key ) def secret_unbox( key: bytes, nonce: bytes, tag: bytes, ct: bytes, ad: bytes = b"" ) -> bytes: """ secret_unbox takes a key, nonce, associated data, ciphertext, and tag, verifies the tag, and if successful decrypts the ciphertext under the given key and nonce, returning the plaintext. If verification fails an exception is raised. Again from the RFC: "Decryption is similar with the following differences: o The roles of ciphertext and plaintext are reversed, so the ChaCha20 encryption function is applied to the ciphertext, producing the plaintext. o The Poly1305 function is still run on the AAD and the ciphertext, not the plaintext. o The calculated tag is bitwise compared to the received tag. The message is authenticated if and only if the tags match. A few notes about this design: 1. The amount of encrypted data possible in a single invocation is 2^32-1 blocks of 64 bytes each, because of the size of the block counter field in the ChaCha20 block function. This gives a total of 274,877,906,880 bytes, or nearly 256 GB. This should be enough for traffic protocols such as IPsec and TLS, but may be too small for file and/or disk encryption. For such uses, we can return to the original design, reduce the nonce to 64 bits, and use the integer at position 13 as the top 32 bits of a 64-bit block counter, increasing the total message size to over a million petabytes (1,180,591,620,717,411,303,360 bytes to be exact). 2. Despite the previous item, the ciphertext length field in the construction of the buffer on which Poly1305 runs limits the ciphertext (and hence, the plaintext) size to 2^64 bytes, or sixteen thousand petabytes (18,446,744,073,709,551,616 bytes to be exact). The AEAD construction in this section is a novel composition of ChaCha20 and Poly1305. A security analysis of this composition is given in [Procter]. Here is a list of the parameters for this construction as defined in Section 4 of [RFC5116]: o K_LEN (key length) is 32 octets. o P_MAX (maximum size of the plaintext) is 274,877,906,880 bytes, or nearly 256 GB. o A_MAX (maximum size of the associated data) is set to 2^64-1 octets by the length field for associated data. o N_MIN = N_MAX = 12 octets. o C_MAX = P_MAX + tag length = 274,877,906,896 octets. Distinct AAD inputs (as described in Section 3.3 of [RFC5116]) shall be concatenated into a single input to AEAD_CHACHA20_POLY1305. It is up to the application to create a structure in the AAD input if it is needed." """ return crypto_aead_chacha20poly1305_ietf_decrypt_detached( ct, tag, ad, nonce, key ) def stream_kdf(n: int, k: bytes, inpt: bytes = b"") -> list[bytes]: """Returns a length-n list of length-KLEN strings of pseudo-random bytes derived from key k If inpt is given, then first a new key k' is computed as the output of chacha under k and the first 16 bytes of inpt given as the combined counter and nonce. That is, the state of the cipher is initialized to |'expand 32-byte k'| | k[:16] | | k[16:32] | | inpt[:16] | # 8-byte counter || 8-byte nonce The first 256-bits of output of this stream are assigned to a new key k', and the process is repeated with the second 16 bytes of inpt and the new key, i.e. |'expand 32-byte k'| | k'[:16] | | k'[16: 32] | | inpt[16:32] | This is then used to generate a stream from which new keys are derived. """ if len(k) != KLEN or not isinstance(k, bytes): raise ValueError(f"k must be {KLEN} bytes") if inpt: if len(inpt) != KLEN or not isinstance(inpt, bytes): raise ValueError(f"inpt must be {KLEN} bytes") k = stream_xor_ic( b"\x00" * KLEN, # Message inpt[8:16], # nonce int.from_bytes(inpt[:8], "little"), # Counter k, # key ) s = stream_xor_ic( b"\x00" * KLEN * n, # Message inpt[24:], # nonce int.from_bytes(inpt[16:24], "little"), # counter k, ) else: s = stream_xor_ic(b"\x00" * KLEN * n, b"\x00" * 8, 0, k) return [s[i * KLEN : (i + 1) * KLEN] for i in range(n)] def h(x: bytes) -> bytes: ctx = shake_256() ctx.update(x) return ctx.digest(HLEN) pqconnect-1.2.1/src/pqconnect/common/util.py0000644000000000000000000000563014733452565017625 0ustar rootrootimport importlib.metadata from grp import getgrnam from os import ( getegid, geteuid, getgid, getgroups, getuid, setgid, setgroups, setuid, ) from pwd import getpwnam from sys import exit as bye from typing import Any, Callable from pqconnect.log import logger from .constants import EPOCH_DURATION_SECONDS class ExistingNftableError(Exception): """Raised if a table already exists when we try to add it to nftables""" pass class NftablesError(Exception): """Raised when an nftables operation cannot be performed""" pass def run_as_user(user: str) -> Callable: """Parameterized decorator to run a function as another user. This changes the uid and gid for the entire interpreter process """ def decorator(func: Callable) -> Callable: def wrapper(*args: Any) -> Callable: try: u = getpwnam(user) g = getgrnam(user) # Privilege separation setgroups([g.gr_gid]) setgid(g.gr_gid) setuid(u.pw_uid) logger.warning( f"Running as uid: {getuid()} euid: {geteuid()} " + f"gid: {getgid()} euid: {getegid()} " + f"groups: {getgroups()}" ) except KeyError: logger.exception( f"Privilege separation user {user} cannot be found." ) bye(1) except PermissionError as e: logger.exception( f"Unable to run as unprivileged user: {user}." ) bye(1) return func(*args) return wrapper return decorator def round_timestamp(timestamp: float) -> int: """Returns the given timestamp rounded down to the nearest epoch""" return int(timestamp // EPOCH_DURATION_SECONDS * EPOCH_DURATION_SECONDS) def base32_encode(x: bytes) -> str: """Follows the base32 encoding used in DNScurve, described in https://tools.ietf.org/id/draft-dempsky-dnscurve-01.html#rfc.section.3 """ alph = dict(zip(range(32), "0123456789bcdfghjklmnpqrstuvwxyz")) return "".join( alph[(int.from_bytes(x, "little") >> i) & 0x1F] for i in range(0, len(x) * 8, 5) ) class Base32DecodeError(ValueError): pass def base32_decode(x: str) -> bytes: """Inverse of base32_encode""" alph = dict(zip("0123456789bcdfghjklmnpqrstuvwxyz", range(32))) x = str(x).lower() if not all(c in alph.keys() for c in x): raise Base32DecodeError() return sum(alph[x[i]] << (5 * i) for i in range(len(x))).to_bytes( (5 * len(x) // 8), "little" ) def display_version() -> None: try: VERSION = importlib.metadata.version("pqconnect") print(f"PQConnect version {VERSION}") except Exception: logger.error("Error: Coult not determine version number") pqconnect-1.2.1/src/pqconnect/cookie/0000755000000000000000000000000014733452565016253 5ustar rootrootpqconnect-1.2.1/src/pqconnect/cookie/__init__.py0000644000000000000000000000000014733452565020352 0ustar rootrootpqconnect-1.2.1/src/pqconnect/cookie/cookie.py0000644000000000000000000000737414733452565020111 0ustar rootrootfrom __future__ import annotations from struct import pack, unpack_from from typing import Tuple from ..common.constants import ( COOKIE_PREFIX, COOKIE_PREFIX_LEN, TIDLEN, TIMESTAMP_LEN, ) from ..common.crypto import NLEN, TAGLEN, secret_box # PREFIX | TID_BYTES | NONCE | CT (TID, Epoch_BYTES, SND_ROOT, RCV_ROOT) | ATAG COOKIE_LEN = ( COOKIE_PREFIX_LEN + len(pack("!Q", 0)) + NLEN + len(pack("!L", 0)) + 3 * TIDLEN # TID, SEND_ROOT, RECV_ROOT + TAGLEN ) class InvalidCookieMsgException(Exception): """Raised when a cookie is malformed""" class Cookie: def __init__(self, timestamp: int, nonce: bytes, ct: bytes, tag: bytes): self._timestamp: int = timestamp self._nonce: bytes = nonce self._ct: bytes = ct self._auth_tag: bytes = tag def timestamp(self) -> int: return self._timestamp def nonce(self) -> bytes: return self._nonce def ct(self) -> Tuple[bytes, bytes]: return self._ct, self._auth_tag @staticmethod def _parse_cookie_timestamp(bts: bytes) -> int: """Parse and return timestamp from a cookie packet""" try: (ts,) = unpack_from("!Q", bts, offset=COOKIE_PREFIX_LEN) except Exception as e: raise InvalidCookieMsgException from e return ts @staticmethod def _parse_cookie_nonce(bts: bytes) -> bytes: """Parse and return nonce from a cookie""" try: nonce = bts[ COOKIE_PREFIX_LEN + TIMESTAMP_LEN : COOKIE_PREFIX_LEN + TIMESTAMP_LEN + NLEN ] except Exception as e: raise InvalidCookieMsgException from e return nonce @staticmethod def _parse_cookie_ct(bts: bytes) -> tuple[bytes, bytes]: """Parse and return cookie ciphertext and authentication tag""" try: ct = bts[COOKIE_PREFIX_LEN + TIMESTAMP_LEN + NLEN : -TAGLEN] tag = bts[-TAGLEN:] except Exception as e: raise InvalidCookieMsgException from e return ct, tag @classmethod def from_session_values( cls, key: bytes, nonce: bytes, timestamp: int, tid: bytes, epoch: int, send_chain_root: bytes, recv_chain_root: bytes, ) -> Cookie: """Creates a Cookie object from session values and a provided cookie key/nonce """ if not all( isinstance(x, bytes) for x in [key, nonce, tid, send_chain_root, recv_chain_root] ): raise TypeError() if not all(isinstance(x, int) for x in [timestamp, epoch]): raise TypeError ts_bts = pack("!Q", timestamp) epoch_bts = pack("!L", epoch) ct, tag = secret_box( key, nonce, b"".join([tid, epoch_bts, send_chain_root, recv_chain_root]), ts_bts, ) return cls(timestamp, nonce, ct, tag) @classmethod def from_bytes(cls, packet: bytes) -> Cookie: if not isinstance(packet, bytes): raise TypeError if len(packet) != COOKIE_LEN: raise InvalidCookieMsgException if packet[:COOKIE_PREFIX_LEN] != COOKIE_PREFIX: raise InvalidCookieMsgException timestamp = cls._parse_cookie_timestamp(packet) nonce = cls._parse_cookie_nonce(packet) ct, tag = cls._parse_cookie_ct(packet) return cls(timestamp, nonce, ct, tag) def bytes(self) -> bytes: """Returns a packed binary cookie blob to send over the network""" ts_bts = pack("!Q", self._timestamp) return b"".join( [COOKIE_PREFIX, ts_bts, self._nonce, self._ct, self._auth_tag] ) pqconnect-1.2.1/src/pqconnect/cookie/cookiemanager.py0000644000000000000000000001513614733452565021437 0ustar rootrootfrom struct import pack, unpack_from from threading import Event, Lock, Thread from time import monotonic from typing import Optional from SecureString import clearmem from ..common.constants import ( COOKIE_PREFIX, COOKIE_PREFIX_LEN, EPOCH_DURATION_SECONDS, EPOCH_TIMEOUT_SECONDS, TIDLEN, ) from ..common.crypto import KLEN, NLEN, randombytes, secret_unbox, stream_kdf from ..common.util import round_timestamp from ..log import logger from ..tunnel import TunnelSession from .cookie import Cookie class TimestampError(Exception): """Called when an invalid or non-existant timestamp is encountered""" class ExhaustedNonceSpaceError(Exception): """Called in the unlikely event that we have exhausted the noncespace for a particular cookie key """ class CookieManager: """CookieManager creates, verifies, decrypts, and updates keys for session cookies. When the server has too many connections, it can store its state with a given client as a cookie and send it to the client, encrypted under the current epoch's cookie key. This is simply a mechanism for outsourcing state and is not meant to protect against abuse by malicious clients. When the client wishes to resume communication with the server it sends its cookie to the server, and the server will reconstruct its state with the client if the cookie is valid. """ def __init__(self, master_cookie_key: bytes, seed: bytes = b""): self._keystore: dict[int, dict] = dict() # include seed as a parameter to make it testable if not seed: seed = randombytes(KLEN) (self._state,) = stream_kdf(1, master_cookie_key, seed) self._end_cond = Event() self._mut = Lock() self._update_thread = Thread(target=self._run_update) def _delete_cookie_key(self, timestamp: int) -> bool: """Erases key in the keystore with the given timestamp. Calling function is responsible for acquiring mutex. Returns True if key successfully deleted. """ try: clearmem(self._keystore[timestamp]["key"]) del self._keystore[timestamp] except KeyError: logger.exception("timestamp not found.") return False return True def _update_deterministic(self, timestamp: int, randomness: bytes) -> None: """Deterministic update function. Should be thread-safe without the mutex but adding just to be sure. """ # Add new cookie key for this epoch, using both the last key and # new randomness as input to the KDF with self._mut: if timestamp not in self._keystore.keys(): (self._state,) = stream_kdf(1, self._state, randomness) new_key = bytes([a for a in self._state]) # store cookie key along with a counter nonce self._keystore[timestamp] = {"key": new_key, "ctr": 0} # Delete cookie keys for expired epochs old_tss = [] for ts in self._keystore.keys(): if ts < (timestamp - EPOCH_TIMEOUT_SECONDS): old_tss.append(ts) for ts in old_tss: self._delete_cookie_key(ts) def _update(self) -> None: """Updates the current cookie key and deletes expired keys""" now = round_timestamp(monotonic()) new_randomness = randombytes(KLEN) self._update_deterministic(now, new_randomness) def _increment_nonce(self, ts: int) -> None: """Increments the counter nonce for the given epoch's cookie key. Should only be called after acquiring mutex. """ # make sure ts is a valid ts, otherwise if ts != round_timestamp(ts): raise TimestampError # Check if we've somehow issued too many cookies under this key if (self._keystore[ts]["ctr"] + 1) >= (1 << (NLEN)): raise ExhaustedNonceSpaceError self._keystore[ts]["ctr"] += 1 def get_cookie_key(self, ts: Optional[int] = None) -> tuple[bytes, bytes]: """Returns the key stored for the given timestamp as well as the current nonce. Raises a ValueError if the key does not exist. """ if ts is None: ts = round_timestamp(monotonic()) else: ts = round_timestamp(ts) try: with self._mut: key = self._keystore[ts]["key"] nonce = self._keystore[ts]["ctr"].to_bytes(NLEN, "big") self._increment_nonce(ts) except KeyError as e: raise ValueError(f"Invalid timestamp") from e return key, nonce def _run_update(self) -> None: """Runs the _update method every EPOCH_DURATION_SECONDS seconds until the cv is set (by calling the stop method). """ while not self._end_cond.is_set(): self._update() self._end_cond.wait(timeout=EPOCH_DURATION_SECONDS) def check_cookie(self, pkt: bytes) -> TunnelSession: """Verifies and decrypts the cookie if it exists, and returns the resulting TunnelSession along with remaining packet data if successful. """ cookie = Cookie.from_bytes(pkt) ts = cookie.timestamp() ts_bts = pack("!Q", ts) # Get nonce nonce = cookie.nonce() # Get ciphertext ct, tag = cookie.ct() # Get cookie key and verify + decrypt try: key, _ = self.get_cookie_key(ts) pt = secret_unbox(key, nonce, tag, ct, ts_bts) except KeyError as e: raise TimestampError from e # Data has been verified and decrypted. tid = pt[:TIDLEN] pt = pt[TIDLEN:] (epoch,) = unpack_from("!L", pt) pt = pt[4:] send_root = pt[:KLEN] recv_root = pt[KLEN:] # Return tunnel along with remaining packet data return TunnelSession.from_cookie_data(tid, epoch, send_root, recv_root) @staticmethod def is_cookie(msg: bytes) -> bool: return msg[:COOKIE_PREFIX_LEN] == COOKIE_PREFIX def stop(self) -> None: """Stops the update thread""" self._end_cond.set() # delete the keys in the keystore to_delete = [] with self._mut: for ts in self._keystore.keys(): to_delete.append(ts) for ts in to_delete: self._delete_cookie_key(ts) self._keystore.clear() def start(self) -> None: """Starts the update thread to ratchet forward the cookie key for each new epoch. """ self._update_thread.start() pqconnect-1.2.1/src/pqconnect/dns_parse.py0000644000000000000000000000337414733452565017341 0ustar rootrootimport sys from pqconnect.common.constants import ( DNS_ENCODED_HASH_LEN, DNS_ENCODED_PORT_LEN, SUPPORTED_MAJOR_VERSIONS, ) from pqconnect.common.util import Base32DecodeError, base32_decode def parse_pq1_record(name: str) -> tuple: """Parses a keyhash and (possibly) port numbers from a pqconnect advertisement in DNS""" if not isinstance(name, str): raise TypeError() names = name.split(".") # from left to right for component in names: # starts with pq1, pq2, etc. if ( len(component) > 2 and component[:2] == "pq" and component[2] in SUPPORTED_MAJOR_VERSIONS ): data = component[3:] try: if len(data) == DNS_ENCODED_HASH_LEN: keyhash = base32_decode(data) return (keyhash,) elif len(data) == ( DNS_ENCODED_HASH_LEN + DNS_ENCODED_PORT_LEN + DNS_ENCODED_PORT_LEN ): idx = DNS_ENCODED_HASH_LEN keyhash = base32_decode(data[:idx]) pqcport = int( base32_decode( data[idx : idx + DNS_ENCODED_PORT_LEN] ).hex(), 16, ) idx += DNS_ENCODED_PORT_LEN keyport = int( base32_decode( data[idx : idx + DNS_ENCODED_PORT_LEN] ).hex(), 16, ) return (keyhash, pqcport, keyport) except Base32DecodeError: return () return () pqconnect-1.2.1/src/pqconnect/dnsproxy.py0000644000000000000000000001023214733452565017240 0ustar rootrootfrom multiprocessing import Event, Pipe from multiprocessing.connection import Connection from multiprocessing.synchronize import Event as ev from select import select from signal import SIGINT, SIGTERM, signal from socket import AF_NETLINK, SOCK_RAW, fromfd from sys import exit as bye from types import FrameType from typing import Optional from netfilterqueue import NetfilterQueue, Packet from .common.constants import MAGIC_NUMBER from .common.util import NftablesError from .log import logger from .nft import NfqueueBuilder class DNSNetfilterProxy: """Proxies DNS responses queued from netfilter. The constructor uses netfilter_queue to create a new firewall table and filter rule that queues DNS packets with source port 53 to the bound socket. The actual packet mangling is done by piping the packet to the client process running as an unprivileged user. The mangled packet is piped back and accepted by the queue, which hands it back to netfilter to return to the calling process. """ def __init__( self, conn: Connection, netfilter_table: str = "pqconnect-filter", end_cond: Optional[ev] = None, ) -> None: self._conn = conn self._nfq = NetfilterQueue() # Add nftables table try: self._nfq_builder = NfqueueBuilder(netfilter_table) self._queue_no = self._nfq_builder.build() except Exception: logger.exception(f"Could not alter nftables") bye(1) # Dummy pipe for terminating the select poll self._end_cond: ev = end_cond if end_cond else Event() # Set signal handler to gracefully exit the run loop signal(SIGINT, self._signal_handle) signal(SIGTERM, self._signal_handle) def _signal_handle(self, signum: int, frame: Optional[FrameType]) -> None: """Sends an empty byte string to the connection that listens for a shutdown signal. """ self._end_cond.set() def onResponse(self, msg: Packet) -> None: """Queued packets are sent here for handling and verdict (drop/accept). However, this process has to run as root in order to bind to the queue, and the incoming packets are hazmat. We render the verdict here but send packets via the pipe to the unprivileged client process for parsing and mangling. """ pkt = msg.get_payload() # We can clearly skip any packets not containing the magic number # anywhere. Requires no parsing and probably saves a lot of time. # U+1f44D U+1f44D if MAGIC_NUMBER not in pkt: msg.accept() return # else the magic number is somewhere. Export it for mangling self._conn.send_bytes(pkt) new_msg = self._conn.recv_bytes() msg.set_payload(new_msg) msg.accept() def _run_queue(self) -> None: """Gets a raw netlink socket from the nfqueue file descriptor and listens for packets arriving in the netfilter queue until a SIGINT or SIGTERM is received. """ s = fromfd(self._nfq.get_fd(), AF_NETLINK, SOCK_RAW) while not self._end_cond.is_set(): r, _, _ = select([s], [], [], 0.1) if s in r: self._nfq.run_socket(s) s.close() def run(self) -> None: """Run the netfilterqueue""" # Bind the netfilter queue to our listener try: self._nfq.bind(self._queue_no, self.onResponse) except Exception: logger.exception("Could not bind queue") self.close() bye(2) # Everything is ready. Run the queue try: self._run_queue() except Exception: logger.exception("Error occurred while running netfilter queue") bye(3) finally: self.close() def close(self) -> None: """Teardown""" self._nfq.unbind() # Delete the nftables table try: self._nfq_builder.tear_down() except NftablesError: logger.exception("Error occured when deleting nftables table") DNSProxy = DNSNetfilterProxy pqconnect-1.2.1/src/pqconnect/iface.py0000644000000000000000000001153514733452565016430 0ustar rootrootfrom fcntl import ioctl from io import FileIO from ipaddress import IPv4Network, ip_network from multiprocessing import Event from multiprocessing.connection import Connection from multiprocessing.synchronize import Event as EventClass from os import read, write from select import select from socket import AF_INET, inet_pton from struct import pack from typing import List, Optional, Tuple from pyroute2 import IPRoute from pqconnect.log import logger class AddressAlreadyInUseException(Exception): """Raised when a TUN device is assigned an address space that is in use by a different interface """ def __init__(self, addr: str, prefix_len: int) -> None: super().__init__( f"Cannot create TUN device with address {addr}/{prefix_len}. " "Address is already in use." ) def get_existing_iface_addresses() -> List[IPv4Network]: """Returns a list of ip address spaces currently assigned to network interfaces on the host """ ip = IPRoute() ip_dump = ip.addr("dump") ip.close() addr_list = [] for record in ip_dump: record_prefix_len = record["prefixlen"] record_prefix = record.prefix record_addr = record.get_attr(f"{record_prefix}LOCAL") if record_addr: addr_list.append( IPv4Network((record_addr, record_prefix_len), strict=False) ) return addr_list def find_free_network( addr_list: List[IPv4Network], prefix_length: int ) -> IPv4Network: """Given a list of (IP, prefix-length) tuples and desired prefix-length, returns a non-overlapping RFC 1918 address space of the desired size """ private_addresses = [ IPv4Network("10.0.0.0/8", strict=False), IPv4Network("172.16.0.0/12", strict=False), IPv4Network("192.168.0.0/16", strict=False), ] existing_subnets = addr_list for network in private_addresses: if prefix_length < network.prefixlen: continue for subnet in network.subnets(new_prefix=prefix_length): if all( not subnet.overlaps(existing) for existing in existing_subnets ): return subnet raise ValueError() def check_overlapping_address( address: str, prefix_len: int, iface_addrs: List[IPv4Network] = [] ) -> bool: """Checks existing network interfaces and returns True if the address/prefix does not overlap with addresses assigned to other network devices. There is a potential ToCToU issue here, but address allocation is generally static and this is mainly to be used as a rough check. Optional keyword argument iface_addrs is a list of (address, prefix_len) tuples """ # Take list from parameters if iface_addrs: addr_list = iface_addrs # Build list from existing interfaces else: addr_list = get_existing_iface_addresses() test_net = IPv4Network((address, prefix_len), strict=False) return all([not test_net.overlaps(addr) for addr in addr_list]) def create_tun_interface(name: str, addr: str, prefix_len: int) -> FileIO: """Creates a new TUN interface and assigns it a local IP address and routing mask """ if not check_overlapping_address(addr, prefix_len): try: existing_addresses = get_existing_iface_addresses() free_subnet = find_free_network(existing_addresses, prefix_len) addr = str(free_subnet.network_address) except ValueError as e: raise AddressAlreadyInUseException(addr, prefix_len) from e tun = open( "/dev/net/tun", "r+b", buffering=0 ) # ioctl constants from TUNSETIFF = 0x400454CA IFF_TUN = 0x0001 IFF_NO_PI = 0x1000 flags = IFF_TUN | IFF_NO_PI ifr = pack("16sH", name.encode("utf-8"), flags) ioctl(tun, TUNSETIFF, ifr) # Sanity check. Verify device exists ip = IPRoute() devs = ip.link_lookup(ifname=name) try: device_no = devs[0] except IndexError: raise Exception("Could not create TUN device. Device does not exist.") # Assign an address space to the device ip.addr("add", index=device_no, address=addr, prefixlen=prefix_len) # set the device status to up ip.link("set", index=device_no, state="up", mtu=1200) ip.close() return tun def tun_listen(tun_file: FileIO, conn: Connection, evt: EventClass) -> None: """Relays messages between the TUN device and the client process. The calling process can terminate it by setting the condition variable @event """ while True: if evt.is_set(): break r, _, _ = select([conn, tun_file], [], [], 0.5) if conn in r: data = conn.recv_bytes() write(tun_file.fileno(), data) if tun_file in r: data = read(tun_file.fileno(), 4096) conn.send_bytes(data) pqconnect-1.2.1/src/pqconnect/keygen.py0000644000000000000000000001670114733452565016643 0ustar rootrootimport logging from grp import getgrnam from os import chmod, environ, remove, rmdir, stat, umask from os.path import exists, isdir, isfile, join from pathlib import Path from pwd import getpwnam from sys import exit as bye import click from pqconnect.common.constants import ( CONFIG_PATH, DEFAULT_KEYPATH, KEYPORT, PQCPORT, PRIVSEP_USER, ) from pqconnect.common.crypto import dh, randombytes, skem from pqconnect.common.util import base32_encode from pqconnect.keys import PKTree def check_config_path(config_path: str, dns_only: bool) -> bool: """Checks that config directory exists, and if not, creates one at CONFIG_PATH, owned by root but visible to everyone. """ if not isdir(config_path): if dns_only: return False ans = input( f"{config_path} does not exist. Would you like to create it? [y/N]" ) if ans != "y": return False umask(0o022) Path(config_path).mkdir(parents=True) return True def get_port_from_config( config_path: str, port_name: str, dns_only: bool ) -> int: port_file = join(config_path, port_name) if not isfile(port_file): if dns_only: raise FileNotFoundError() if port_name == "keyport": ans = input( f"Please enter the listening port of your keyserver (Default: {KEYPORT})" ) if ans == "": ans = str(KEYPORT) elif port_name == "pqcport": ans = input( f"Please enter the listening port for the PQConnect server (Default: {PQCPORT})" ) if ans == "": ans = str(PQCPORT) port = int(ans) if port not in range(1, 1 << 16): raise ValueError umask(0o022) with open(port_file, "w") as f: f.write(ans) with open(port_file, "r") as f: port = int(f.read().strip()) port &= 65535 return port def save_keys( keypath: str, mceliece_pk: bytes, mceliece_sk: bytes, x25519_pk: bytes, x25519_sk: bytes, sk: bytes, ) -> bool: """Saves the given keys to disk""" pqconnect_uid = getpwnam(PRIVSEP_USER).pw_uid pqconnect_gid = getgrnam(PRIVSEP_USER).gr_gid files = { "mceliece_pk": mceliece_pk, "mceliece_sk": mceliece_sk, "x25519_pk": x25519_pk, "x25519_sk": x25519_sk, "session_key": sk, } try: umask(0o022) if not isdir(keypath): ans = input( f"{keypath} does not exist. Would you like to create it? [y/N]" ) if ans != "y": return False else: Path(keypath).mkdir(parents=True) for filename in ["mceliece_pk", "x25519_pk"]: path = join(keypath, filename) if exists(path): ans = input(f"{path} exists. Overwrite? [y/N]: ") if ans != "y": return False with open(path, "wb") as f: f.write(files[filename]) f.close() umask(0o077) for filename in ["mceliece_sk", "x25519_sk", "session_key"]: path = join(keypath, filename) if exists(path): ans = input(f"{path} exists. Overwrite? [y/N]: ") if ans != "y": return False with open(path, "wb") as f: f.write(files[filename]) f.close() except Exception: return False return True def log_key_info(keypath: str, pqcport: int, keyport: int) -> bool: """Writes public key hash and DNS information to log""" DNS_HOWTO = join(keypath, "DNS_Record_Update_HOWTO.txt") logging.basicConfig( level=logging.INFO, format="%(message)s", filename=DNS_HOWTO, filemode="w", ) console = logging.StreamHandler() console.setLevel(logging.INFO) logging.getLogger().addHandler(console) try: tree = PKTree.from_file( join(keypath, "mceliece_pk"), join(keypath, "x25519_pk"), ) logging.info( "\nThe public key hash for your keys is: " f"{tree.get_base32_encoded_pubkey_hash()}\n" ) full_name = ( tree.get_base32_encoded_pubkey_hash() + base32_encode(bytes.fromhex(hex(65536 + pqcport)[-4:])) + base32_encode(bytes.fromhex(hex(65536 + keyport)[-4:])) ) logging.info( "Please update your DNS A/AAAA records for all domains on this " "server as follows:\n\n" "Existing record:\n" "Type Name Value\n" "A/AAAA SUBDOMAIN IP Address\n\n" "New Records:\n" "Type Name Value\n" f"CNAME SUBDOMAIN pq1{full_name}" ".DOMAIN.TLD\n" f"A/AAAA pq1{full_name} IP Address\n" ) logging.info( "IMPORTANT: If SUBDOMAIN has NS records, do not make this change.\n" "Instead set up another SUBDOMAIN for the server.\n" ) print(f"\n\nView the file {DNS_HOWTO} for a copy of the above output.") except Exception: logging.exception("Could not load keys from disk") return False return True def static_keygen( keypath: str, pqcport: int, keyport: int, dns_only: bool ) -> bool: """Generates static keys and writes each key to keypath/""" # Generate keys if dns_only: return log_key_info(keypath, pqcport, keyport) try: # Generate McEliece keys print("Generating McEliece keypair") mceliece_pk, mceliece_sk = skem.keypair() # Generate ECC keys print("Generating X25519 keypair") x25519_pk, x25519_sk = dh.dh_keypair() # Generate and save Session key print("Generating symmetric session key") sk = randombytes(32) print("Keys generated successfully") except Exception: logging.exception("Could not generate keys") return False # Save to disk if not save_keys( keypath, mceliece_pk, mceliece_sk, x25519_pk, x25519_sk, sk ): logging.exception("Could not save keys to disk") return False return log_key_info(keypath, pqcport, keyport) @click.command() @click.option( "-c", "--config-dir", type=str, default=CONFIG_PATH, help="PQConnect server config directory", ) @click.option( "-d", "--directory", type=str, default=DEFAULT_KEYPATH, help="directory where key files will be stored", ) @click.option( "-D", "--dns-only", is_flag=True, default=False, help="print DNS records for existing configuration", ) def main(directory: str, config_dir: str, dns_only: bool) -> None: """Generate and save long term keys""" # Log the DNS update information to a file keypath = directory if not check_config_path(config_dir, dns_only): print("Could not locate config directory") bye(1) try: pqcport = get_port_from_config(config_dir, "pqcport", dns_only) keyport = get_port_from_config(config_dir, "keyport", dns_only) except ValueError: print("Invalid port configuration") bye(2) if not static_keygen(keypath, pqcport, keyport, dns_only): print("Error occurred during key generation") bye(3) bye() if __name__ == "__main__": main() pqconnect-1.2.1/src/pqconnect/keys.py0000644000000000000000000002632514733452565016337 0ustar rootrootfrom __future__ import annotations from abc import ABC, abstractmethod from typing import Any, Dict, Optional from pymceliece import mceliece6960119, mceliece8192128 from .common.constants import SEG_LEN from .common.crypto import HLEN, dh, h, skem from .common.util import base32_encode class InvalidNodeException(Exception): """Raised when a non-existent node index is requested""" def __init__(self, depth: int, pos: int) -> None: super().__init__(f"No node at position {depth}, {pos}") def _get_tree_dimensions(pklen: int, node_length: int) -> list[int]: """Returns the dimensions of a Merkle tree whose leaves contain a public key of length PKLEN (plus a 32-byte DH key) with node size equal to node_length Examples: >>> _get_tree_dimensions(1234, 64) [1, 1, 2, 3, 5, 10, 20] >>> _get_tree_dimensions(1357824, 1152) [1, 1, 33, 1179] >>> _get_tree_dimensions(1047319, 1152) [1, 1, 26, 910] """ if node_length < HLEN: raise ValueError ret = [] tot_bts = pklen + dh.lib25519_dh_PUBLICKEYBYTES # +32 while tot_bts > HLEN: # levels below the root num_nodes = (tot_bts + node_length - 1) // node_length # ceil ret.append(num_nodes) tot_bts = num_nodes * HLEN ret.append(1) # 1 node at level 0 (root) ret.reverse() return ret class _PKTree(ABC): @classmethod @abstractmethod def from_file(cls, pq_path: str, npq_path: str) -> _PKTree: pass @staticmethod @abstractmethod def get_children_range(depth: int, pos: int) -> tuple[int, int]: pass def __init__( self, pklen: int, pqpk: Optional[bytes] = None, npqpk: Optional[bytes] = None, ) -> None: """Builds a nested dict of key, dict pairs. The level 1 keys represent the depth of the tree. The keys of the inner dicts are the left-right positions of each packet in that level of the tree. The values of the inner dicts are contents of each packet the client can request. Follows the description in section 3.2.1 of the PQConnect thesis, using mceliece8192128: - The 1357856-byte mceliece8192128 key is split into 1179 parts, each part (before the last) having 1152 bytes. The 32-byte long-term x25519 public key is appended to the last part, which becomes 800 bytes in total. (Depth 3) - Each leaf node is hashed separately, producing 37728 bytes in total. - These 37728 bytes are split into 33 groups of 36 hashes, again each part (before the last) having 1152 bytes. (Depth 2) - Each level 2 node is hashed independently. The hashes are concatenated, producing a single node of 1056 bytes. (Depth 1) - These 1056 bytes are hashed, producing the root of the tree. (Depth 0) () | () / ...|...\ 33: ()....()...() /......|......\ 1179: ()......().....() """ self._tree: dict = {0: {}, 1: {}, 2: {}, 3: {}} # a dictionary holding the tree dimensions at each depth level dimensions = _get_tree_dimensions(pklen, SEG_LEN) self._struct: Dict[int, int] = dict( zip(range(len(dimensions)), dimensions) ) if not pqpk or not npqpk: return if not (isinstance(pqpk, bytes) and isinstance(npqpk, bytes)): raise TypeError if len(pqpk) != pklen: raise ValueError if len(npqpk) != dh.lib25519_dh_PUBLICKEYBYTES: raise ValueError # Depth 3 for i in range(pklen // SEG_LEN + 1): self._tree[3][i] = pqpk[ i * SEG_LEN : min((i + 1) * SEG_LEN, pklen) ] # Append X25519 key to last node self._tree[3][self._struct[3] - 1] += npqpk # Depth 2 # Concatenate hashes of level 3 nodes hash_bts = b"".join( [h(self._tree[3][i]) for i in range(len(self._tree[3]))] ) # Divide bytes among level 2 nodes for i in range(len(hash_bts) // SEG_LEN + 1): self._tree[2][i] = hash_bts[ i * SEG_LEN : min((i + 1) * SEG_LEN, len(hash_bts)) ] # Depth 1 # Hash and concatenate level 2 nodes into level 1 node self._tree[1][0] = b"".join( [h(self._tree[2][i]) for i in range(len(self._tree[2]))] ) # Depth 0 self._tree[0] = {0: h(self._tree[1][0])} def get_structure(self) -> Dict[int, int]: """Returns the dimensions of the tree as a Dict, indexed by depth""" return self._struct def get_pubkey_hash(self) -> bytes: """Returns the raw public key hash""" return self.get_node(0, 0) def get_base32_encoded_pubkey_hash(self, pk_hash: bytes = b"") -> str: """Returns the base32-encoded hash of the public keys stored in the tree """ if pk_hash: pkh = pk_hash else: pkh = self.get_pubkey_hash() return base32_encode(pkh) def get_node(self, depth: int, pos: int) -> bytes: """Returns the bytes stored at position (depth, pos) in the tree""" try: return self._tree[depth][pos] except KeyError as e: raise InvalidNodeException(depth, pos) from e def get_npqpk(self) -> bytes: """Returns the x25519 public key""" try: return self.get_node(3, self._struct[3] - 1)[ -dh.lib25519_dh_PUBLICKEYBYTES : ] except InvalidNodeException: return b"" def get_pqpk(self) -> bytes: """Returns the McEliece public key""" try: return b"".join( [self.get_node(3, i) for i in range(self._struct[3])] )[: -dh.lib25519_dh_PUBLICKEYBYTES] except InvalidNodeException: return b"" def is_complete(self) -> bool: """Returns true if the full public key data is stored and verified""" return all( [ len(self._tree[i]) == self._struct[i] for i in self._struct.keys() ] ) def insert_node(self, depth: int, pos: int, data: bytes) -> bool: """Verify and insert a packet into the PKTree""" # Return True if the data is already inserted (duplicate packet) if depth in self._tree and pos in self._tree[depth]: return data == self._tree[depth][pos] if self.verify_node(depth, pos, data): self._tree[depth][pos] = data return True return False def verify_node(self, depth: int, pos: int, data: bytes) -> bool: """Returns True if the hash of the data is contained in its parent node at the correct offset """ try: if depth == 0: return True elif depth == 1: return h(data) == self._tree[0][0] elif depth == 2: idx = pos * HLEN return h(data) == self._tree[1][0][idx : idx + HLEN] elif depth == 3: idx = pos * 32 % SEG_LEN return h(data) == self._tree[2][pos // 36][idx : idx + HLEN] else: raise InvalidNodeException(depth, pos) except (InvalidNodeException, KeyError): return False def get_subtree_packets_at_root( self, depth: int, pos: int ) -> list[tuple[int, int]]: """Returns a list of tree indices rooted at the given node... sorta If the root is in the top half of the tree, just return the subtree restricted to the top half of the tree. This limits the result size for practical reasons. examples: PKTree.get_subtree_packets_at_root(0, 0) # [(0, 0), (1, 0)] PKTree.get_subtree_packets_at_root(1, 0) # [(1, 0)] PKTree.get_subtree_packets_at_root(2, 0) # [(2, 0), (3, 0), ... , (3, 35)] PKTree.get_subtree_packets_at_root(2, 32) # [(2, 32), (3, 1152), ... , (3, 1178)] PKTree.get_subtree_packets_at_root(3, 0) # [(3, 0)] """ if depth not in self._struct.keys() or pos > self._struct[depth]: raise InvalidNodeException(depth, pos) ret = [(depth, pos)] if depth == 0: ret.append((1, pos)) elif depth == 1: pass elif depth == 2: for j in range(pos * 36, min((pos + 1) * 36, self._struct[3])): ret.append((3, j)) return ret class _PKTree8192128(_PKTree): @classmethod def from_file(cls, pq_path: str, npq_path: str) -> _PKTree8192128: with open(pq_path, "rb") as f: pq = f.read() f.close() with open(npq_path, "rb") as f: npq = f.read() f.close() return cls(pq, npq) def __init__( self, pqpk: Optional[bytes] = None, npqpk: Optional[bytes] = None ): super().__init__(mceliece8192128.PUBLICKEYBYTES, pqpk, npqpk) @staticmethod def get_children_range(depth: int, pos: int) -> tuple[int, int]: """Returns the range of position indexes of the children of the node at the given position. Excample: get_children_range(0, 0) (0, 1) get_children_range(1, 0) (0, 33) get_children_range(2, 0) (0, 36) get_children_range(2, 32) (1152, 1179) """ if depth not in range(0, 3): raise ValueError elif depth == 0: return (0, 1) elif depth == 1: return (0, 33) else: # depth == 2: return (pos * 36, min((pos + 1) * 36, 1179)) class _PKTree6960119(_PKTree): @classmethod def from_file(cls, pq_path: str, npq_path: str) -> _PKTree6960119: with open(pq_path, "rb") as f: pq = f.read() f.close() with open(npq_path, "rb") as f: npq = f.read() f.close() return cls(pq, npq) def __init__( self, pqpk: Optional[bytes] = None, npqpk: Optional[bytes] = None ) -> None: """Builds a 1-1-26-910-ary Merkle Tree for a McEliece6960119 public key and a X25519 public key """ super().__init__(mceliece6960119.PUBLICKEYBYTES, pqpk, npqpk) @staticmethod def get_children_range(depth: int, pos: int) -> tuple[int, int]: """Returns the range of position indexes of the children of the node at the given position. Excample: get_children_range(0, 0) (0, 1) get_children_range(1, 0) (0, 26) get_children_range(2, 0) (0, 36) get_children_range(2, 25) (1152, 1179) """ if depth not in range(0, 3): raise ValueError elif depth == 0: if pos != 0: raise ValueError return (0, 1) elif depth == 1: if pos != 0: raise ValueError return (0, 26) else: # depth == 2: if pos not in range(0, 26): raise ValueError return (pos * 36, min((pos + 1) * 36, 910)) if skem == mceliece6960119: PKTree: Any = _PKTree6960119 elif skem == mceliece8192128: PKTree = _PKTree8192128 pqconnect-1.2.1/src/pqconnect/keyserver.py0000644000000000000000000001026214733452565017374 0ustar rootrootfrom __future__ import annotations from socket import AF_INET, SOCK_DGRAM, socket from sys import exit as bye from threading import Event, Thread from typing import Optional, Union from pqconnect.common.constants import ( KEYPORT, MCELIECE_PK_PATH, X25519_PK_PATH, ) from pqconnect.keys import PKTree from pqconnect.keystore import EphemeralKey, EphemeralPublicKeystore from pqconnect.log import logger from pqconnect.request import ( EphemeralKeyRequest, EphemeralKeyResponse, KeyRequestHandler, StaticKeyRequest, StaticKeyResponse, ) class KeyServer(Thread): """Simple request/response server over UDP""" def __init__( self, mceliece_pk_path: str = MCELIECE_PK_PATH, x25519_pk_path: str = X25519_PK_PATH, keyport: int = KEYPORT, ) -> None: super().__init__() self._end_cond = Event() self._keystore: Optional[EphemeralPublicKeystore] = None self._port = keyport try: self._pktree = PKTree.from_file(mceliece_pk_path, x25519_pk_path) except FileNotFoundError as e: logger.exception(e) bye(1) self._transport = socket(AF_INET, SOCK_DGRAM) self._transport.bind(("0.0.0.0", self._port)) def set_keystore(self, keystore: EphemeralPublicKeystore) -> None: """sets the keystore""" if not self._keystore: self._keystore = keystore self._keystore.start() else: self._keystore.merge(keystore) def close(self) -> None: """Stopes the pruning thread, closes the listening socket, and deletes all keys in the keystore """ self._end_cond.set() # transport socket is blocking sock = socket(AF_INET, SOCK_DGRAM) sock.sendto(b"", ("0.0.0.0", self._port)) sock.close() self._transport.close() if self._keystore: self._keystore.close() def ephemeral_key_response( self, req: EphemeralKeyRequest ) -> EphemeralKeyResponse: if not self._keystore: raise AttributeError eph_key = self._keystore.get_current_keys() pq, npq = eph_key.public_keys() r = EphemeralKeyResponse(pqpk=pq, npqpk=npq) if len(bytes(req)) == len(bytes(r)): return r raise ValueError def static_key_response(self, req: StaticKeyRequest) -> StaticKeyResponse: depth = req.depth pos = req.pos keydata = self._pktree.get_node(depth, pos) r = StaticKeyResponse(depth=depth, pos=pos, keydata=keydata) if len(bytes(r)) == len(bytes(req)): return r raise ValueError def run(self) -> None: """Run the keyserver""" logger.info("Starting Keyserver") while not self._end_cond.is_set(): data, addr = self._transport.recvfrom(4096) logger.info(f"Request received from {addr}.") # Get request type r = KeyRequestHandler(data).request() if not r: logger.error("Invalid request received.") continue # Handle ephemeral key request if type(r) == EphemeralKeyRequest and self._keystore: try: response: Union[ StaticKeyResponse, EphemeralKeyResponse ] = self.ephemeral_key_response(r) except Exception: logger.exception( f"Invalid ephemeral key request from {addr}" ) continue # Handle static key request elif type(r) == StaticKeyRequest: try: logger.debug( f"Sending static key segment ({r.depth}, {r.pos})" ) response = self.static_key_response(r) except ValueError: logger.exception(f"Invalid static key request fron {addr}") continue # Unknown request type else: continue logger.debug(f"Sending {type(r)} to {addr}") self._transport.sendto(response.payload, addr) pqconnect-1.2.1/src/pqconnect/keystore.py0000644000000000000000000002404414733452565017225 0ustar rootrootfrom __future__ import annotations from threading import Event, Thread from time import time from typing import Dict, Tuple from SecureString import clearmem from pqconnect.common.constants import ( DAY_SECONDS, EPOCH_DURATION_SECONDS, EPOCH_TIMEOUT_SECONDS, ) from pqconnect.common.crypto import dh, ekem from pqconnect.common.util import round_timestamp from pqconnect.log import logger class EphemeralKey: """A simple class to store an ephemeral public key. ... Attributes __________ x25519_pk: Curve25519 public key sntrup_pk: Streamlined NTRU Prime public key start: key timestamp, marking the time after which the key may be used. The key should only be distributed to users between start and start + EPOCH_DURATION_SECONDS. """ def __init__(self, start: int, sntrup: bytes, x25519: bytes): if not isinstance(start, int): raise TypeError("start must be an int") self._start = start if not isinstance(sntrup, bytes): raise TypeError if len(sntrup) != ekem.pklen: raise ValueError("invalid sntrup key") self._sntrup_pk = sntrup if not isinstance(x25519, bytes): raise TypeError if len(x25519) != dh.lib25519_dh_PUBLICKEYBYTES: raise ValueError("invalid dh key") self._x25519_pk = x25519 @property def start(self) -> int: """Returns the start timestamp""" return self._start def public_keys(self) -> Tuple[bytes, bytes]: """Returns the two public keys as (sntrup_pk, x25519_pk)""" return self._sntrup_pk, self._x25519_pk def get_secret_keys(self) -> Tuple[bytes, bytes]: raise NotImplementedError def copy(self) -> EphemeralKey: raise NotImplementedError def clear(self) -> None: raise NotImplementedError class EphemeralPrivateKey(EphemeralKey): """A simple class to store an ephemeral private key and manage its erasure. ... Attributes __________ x25519_pk: Curve25519 public key sntrup_pk: Streamlined NTRU Prime public key x25519_sk: Curve25519 private key sntrup_sk: Streamlined NTRU Prime private key start: int the integer timestamp in seconds since epoch after which the key can be used. The key should only be distributed to users between start and start + EPOCH_DURATION_SECONDS. """ def __init__( self, start: int, sntrup_pk: bytes = b"", sntrup_sk: bytes = b"", x25519_pk: bytes = b"", x25519_sk: bytes = b"", ): if not (sntrup_pk and sntrup_sk): sntrup_pk, sntrup_sk = ekem.keypair() if not isinstance(sntrup_sk, bytes): raise TypeError if len(sntrup_sk) != ekem.sklen: raise ValueError("invalid sntrup key") self.sntrup_sk = sntrup_sk if not (x25519_pk and x25519_sk): x25519_pk, x25519_sk = dh.dh_keypair() if not isinstance(x25519_sk, bytes): raise TypeError if len(x25519_sk) != dh.lib25519_dh_SECRETKEYBYTES: raise ValueError("invalid x25519 key") self.x25519_sk = x25519_sk super().__init__(start, sntrup_pk, x25519_pk) def get_secret_keys(self) -> Tuple[bytes, bytes]: """Returns the two secret keys as (sntrup_sk, x25519_sk)""" return self.sntrup_sk, self.x25519_sk def copy(self) -> EphemeralKey: """returns a deep copy of this ephemeral private key""" sntrup_sk = bytes( [self.sntrup_sk[i] for i in range(len(self.sntrup_sk))] ) x25519_sk = bytes( [self.x25519_sk[i] for i in range(len(self.x25519_sk))] ) return EphemeralPrivateKey( self._start, sntrup_pk=self._sntrup_pk, sntrup_sk=sntrup_sk, x25519_pk=self._x25519_pk, x25519_sk=x25519_sk, ) def clear(self) -> None: """Zeroes out memory of private keys""" clearmem(self.sntrup_sk) clearmem(self.x25519_sk) class Keystore: """Base class to handle stores of a cache of ephemeral keys""" def __init__(self, string: str): self._store: Dict[int, EphemeralKey] = {} self._end_cond = Event() self._pruning_thread = Thread(target=self._prune_old_keys) self._string = string def add(self, key: EphemeralKey) -> None: """Adds the EphemeralKey key to the keystore""" idx = key.start self._store[idx] = key def delete(self, key: EphemeralKey) -> bool: """Deletes the EphemeralKey key from the keystore. If key is an EpehemeralPrivateKey, the key memory is zeroed before returning. Returns True if successful, otherwise False. """ if key.start in self._store: try: _ = self._store.pop(key.start) return True except Exception: pass return False def start(self) -> None: """Start the pruning thread""" self._pruning_thread.start() def close(self) -> None: """Deletes all keys in the keystore""" self._end_cond.set() if self._pruning_thread.is_alive(): logger.log(9, "joining keystore pruning thread") self._pruning_thread.join() logger.log(9, "keystore pruning thread joined") self._store.clear() def get_current_keys(self) -> EphemeralKey: """Returns the pair of currently valid ephemeral public keys in the keystore. """ now = round_timestamp(time()) # calling function should handle KeyError key = self._store[now] return key def get_store(self) -> Dict[int, EphemeralKey]: """Returns the dictionary holding the keys""" return self._store def _prune_old_keys(self, test: bool = False) -> None: """Housekeeping method to be run as a separate thread. Sleeps for EPOCH_DURATION_SECONDS and then deletes all ephemeral keys from the keystore that have expired. """ while not self._end_cond.is_set(): if test: self._end_cond.set() # loop runs once and then exits self._end_cond.wait(timeout=EPOCH_DURATION_SECONDS) old_tss = [] logger.debug(f"Pruning old keys from {self._string}") now = int(time()) old = now - EPOCH_TIMEOUT_SECONDS for start_ts in self._store.keys(): if start_ts < old: old_tss.append(start_ts) else: break for ts in old_tss: self.delete(self._store[ts]) class EphemeralPublicKeystore(Keystore): """Base class to handle stores of a cache of ephemeral public keys""" def __init__(self, store: dict): super().__init__("public store") for idx in store.keys(): key = store[idx] ekem_pk, dh_pk = key.public_keys() new_key = EphemeralKey(idx, ekem_pk, dh_pk) self.add(new_key) def delete(self, key: EphemeralKey) -> bool: """Deletes the EphemeralKey key from the keystore. If key is an EpehemeralPrivateKey, the key memory is zeroed before returning. Returns True if successful, otherwise False. """ if key.start in self._store: self._store.pop(key.start) return True return False def merge(self, remote_keystore: Keystore) -> None: "Copies all the keys from remote_keystore into our keystore" store = remote_keystore.get_store() for idx in store.keys(): self.add(store[idx]) # Stop the pruning thread in other keystore remote_keystore.close() class EphemeralPrivateKeystore(Keystore): """Base class to handle stores of a cache of ephemeral private keys""" def __init__(self, start_time: int): super().__init__("private store") start = round_timestamp(start_time) for i in range(start, start + DAY_SECONDS, EPOCH_DURATION_SECONDS): key = EphemeralPrivateKey(i) self.add(key) def delete(self, key: EphemeralKey) -> bool: """Deletes the EphemeralKey key from the keystore. If key is an EpehemeralPrivateKey, the key memory is zeroed before returning. Returns True if successful, otherwise False. """ if key.start in self._store: try: ephemeral_key = self._store.pop(key.start) ephemeral_key.clear() return True except Exception: pass return False def close(self) -> None: """Deletes all keys in the keystore""" for idx in self._store.keys(): self._store[idx].clear() super().close() def get_public_keystore(self) -> EphemeralPublicKeystore: """Returns a copy of this keystore containing only the public keys""" return EphemeralPublicKeystore(self._store) def get_unexpired_keys(self) -> list[EphemeralKey]: """Returns list of all currently valid private keys in the keystore. This returns the actual key data, not the EphemeralPrivateKey object. For EPOCH_DURATION_SECONDS := 30 seconds and EPOCH_TIMEOUT_SECONDS := 120 seconds, this should yield four sets of ephemeral keys. """ now = round_timestamp(time()) ret = [] for t in range( now, min(self._store.keys()) - 1, -EPOCH_DURATION_SECONDS ): try: ret.append(self._store[t]) except Exception: continue return ret def merge(self, remote_keystore: "EphemeralPrivateKeystore") -> None: "Copies all the keys from remote_keystore into our keystore" store = remote_keystore.get_store() for idx in store.keys(): remote_key = store[idx] new_key = remote_key.copy() self.add(new_key) # Stop the pruning thread in other keystore remote_keystore.close() pqconnect-1.2.1/src/pqconnect/log.py0000644000000000000000000000040014733452565016127 0ustar rootrootimport logging import sys logger = logging.getLogger(__name__) logger.setLevel(logging.INFO) formatter = logging.Formatter("%(asctime)s %(levelname)s: %(message)s") sh = logging.StreamHandler(sys.stderr) sh.setFormatter(formatter) logger.addHandler(sh) pqconnect-1.2.1/src/pqconnect/nft.py0000644000000000000000000001164114733452565016146 0ustar rootrootfrom sys import exit as bye from netfilterqueue import NetfilterQueue from nftables import Nftables from .common.constants import NUM_QUEUE_ATTEMPTS from .common.util import ExistingNftableError, NftablesError from .log import logger class NfqueueBuilder: """Manages the nftables state that allows netfilterqueue to proxy DNS responses to the client. TODO rename since has nothing to do with DNS really """ def __init__(self, table_name: str): self.nfqueue = NetfilterQueue() self.nft = Nftables() self.table_name = table_name # Set queue_num initially to 0. self.queue_num = 0 def _add_table(self, table_name: str) -> None: """Adds a new table to the nftables ruleset with name table_name, or raises an NftablesError """ rc, _, error = self.nft.cmd(f"create table inet {table_name}") if rc != 0 or error: # An error is probably raised either because the table already # exists or we don't have cap_net_admin permissions. if "File exists" in error: raise ExistingNftableError elif "Operation not permitted" in error: raise PermissionError else: raise NftablesError def _add_input_filter_chain(self, table_name: str, priority: int) -> None: """Adds a new input filter chain to the table `table_name` or raises an NftablesError. """ rc, _, error = self.nft.cmd( f"add chain inet {table_name} input {{ " f"type filter hook input priority {priority}; }}" ) if rc != 0 or error: raise NftablesError def _add_dns_queue_rule(self, table_name: str, queue_num: int) -> None: """Adds a new rule to the pqconnect nftables table to queue all UDP packets with source port 53 to the specified `queue_num`. This will intercept any UDP packet with source port 53, regardless of source IP. DNS responses coming from other source ports (for example, if using DoH) will be missed. Dealing with this scenario is a TODO. """ rc, _, error = self.nft.cmd( f"add rule inet {table_name} " f"input udp sport 53 queue num {queue_num}" ) if rc != 0 or error: raise NftablesError def _delete_nftables_table(self, table_name: str) -> str: """Tries to delete the table `table_name`. Returns the error, which should usually be the empty string. Does not raise an exception. """ rc, _, error = self.nft.cmd(f"delete table inet {table_name}") return error def tear_down(self) -> None: """Deletes the table and associated rules. Errors are ignored""" self._delete_nftables_table(self.table_name) def build(self, delete_existing: bool = True) -> int: """Create new table "pqconnect-filter" containing an input chain and rule that queues incoming DNS packets to netfilter_queue for NAT. The chain priority should be less than 0 so that it's handled before the rest of the input filter hook is executed. Setting priority of -10 allows admins to prioritize other pre-filter chains relating to DNS they may have to occur after PQConnect performs NAT on server responses. Returns the queue number if successful """ if delete_existing: # Delete table if it already exists self.tear_down() # Add new nftables table try: self._add_table(self.table_name) except ExistingNftableError: logger.exception(f"Table {self.table_name} already exists.") self.tear_down() bye(1) # Add input chain, use priority -10 just to place before normal input # filtering try: self._add_input_filter_chain(self.table_name, -10) except NftablesError as e: self._delete_nftables_table(self.table_name) logger.exception( f"Could not add rule to nftables ruleset. Exiting..." ) bye(2) # Add queue rule. In case there is already a netfilter_queue running # for a different process we try a range of NUM_QUEUE_ATTEMPTS queue # numbers and stop when we are successful for i in range(NUM_QUEUE_ATTEMPTS): err = None try: self._add_dns_queue_rule(self.table_name, queue_num=i) self.queue_num = i return self.queue_num except NftablesError as e: # continue in the loop, but save the error for later in case # this fails err = e # loop was not interrupted, i.e. we could not add the rule. Exit else: self.tear_down() logger.error(f"Could not set up queue rule: {err}. Exiting...") bye(3) pqconnect-1.2.1/src/pqconnect/pacing/0000755000000000000000000000000014733452565016243 5ustar rootrootpqconnect-1.2.1/src/pqconnect/pacing/__init__.py0000644000000000000000000000000014733452565020342 0ustar rootrootpqconnect-1.2.1/src/pqconnect/pacing/pacing.py0000644000000000000000000004572714733452565020075 0ustar rootrootfrom random import choice from time import monotonic MINCWND = 8192 INITCWND = 24576 BBR_STARTUP = 1 BBR_DRAIN = 2 BBR_PROBEBANDWIDTH = 3 BBR_PROBERTT = 4 bbr_pacing_gain_cycle = (1.25, 0.75, 1, 1, 1, 1, 1, 1) class WindowedMaxSample: def __init__(self, t: float, v: float) -> None: self.t: float = t self.v: float = v class WindowedMax: """ ----- windowed max basically the linux windowed minmax code, modulo data types skip windowedmin: user can negate input """ def __init__(self) -> None: self.s: list = [WindowedMaxSample(0, 0) for i in range(3)] def get(self) -> float: return self.s[0].v def reset(self, t: float, v: float) -> None: self.s[2].t = self.s[1].t = self.s[0].t = t self.s[2].v = self.s[1].v = self.s[0].v = v def subwin_update(self, window: float, t: float, v: float) -> None: dt = t - self.s[0].t if dt > window: self.s.pop(0) self.s.append(WindowedMaxSample(t, v)) if (t - self.s[0].t) > window: self.s.pop(0) self.s.append(WindowedMaxSample(t, v)) elif (self.s[1].t == self.s[0].t) and (dt > (window / 4)): self.s[2].t = self.s[1].t = t self.s[2].v = self.s[1].v = v elif (self.s[2].t == self.s[1].t) and (dt > (window / 2)): self.s[2].t = t self.s[2].v = v def running_max(self, window: float, t: float, v: float) -> None: if v >= self.s[0].v or t - (self.s[2].t) > window: self.reset(t, v) return if v >= self.s[1].v: self.s[2].t = self.s[1].t = t self.s[2].v = self.s[1].v = v elif v >= self.s[2].v: self.s[2].t = t self.s[2].v = v self.subwin_update(window, t, v) class PacingPacket: def __init__(self, numbytes: int) -> None: self.len: int = numbytes self.transmissions: float = 0 self.acknowledged: float = 0 self.transmissiontime: float = 0 self.save_netbytesdelivered: int self.save_netbytesdelivered_time: float self.first_sent_time: float class PacingConnection: def __init__(self) -> None: self._now: float = 0 self._lastsending: float = ( 0 # time of most recent transmission or retransmission ) self._bytesinflight: int = ( 0 # packets transmitted and not yet acknowledged ) self._packetssent: int = 0 self._packetsreceived: int = 0 # at pacing_when, would have been comfortable sending pacing_netbytes self._pacing_when: float = 0 self._pacing_netbytes: int = 0 # setting retransmission timeout: self._rtt_measured: int = ( 0 # 1 if pacing_newrtt() has ever been called ) self._rtt_smoothed: float = 0 # "srtt"; smoothing of rtt measurements self._rtt_variance: float = 0 # "rttvar"; estimate of rtt variance self._rtt_nextdecrease: float = ( 0 # time for next possible decrease of rtt_variance ) self._rtt_mdev: float = 0 # smoothing of deviation from rtt_smoothed self._rtt_mdev_max: float = ( 0 # maximum of rtt_mdev since last possible decrease ) self._rto: float = 1 # retransmission timeout # BBR delivery-rate variables: self._bbrinit_happened: int = 0 self.now_update() self._netbytesdelivered: int = 0 self._netbytesdelivered_time: float = 0 self._first_sent_time: float = 0 self._bbr_state: int = 0 self._bbr_cycle_index: int = 0 self._bbr_cycle_stamp: float = 0 self._bbr_bandwidthfilter: WindowedMax = WindowedMax() self._bbr_rtprop: float = 0 self._bbr_rtprop_stamp: float = 0 self._bbr_rtprop_expired: int = 0 self._bbr_probe_rtt_done_stamp: float = 0 self._bbr_probe_rtt_round_done: int = 0 self._bbr_packet_conservation: int = 0 self._bbr_prior_cwnd: int = 0 self._bbr_idle_restart: int = 0 self._bbr_next_round_delivered: int = 0 self._bbr_round_start: int = 0 self._bbr_round_count: int = 0 self._bbr_filled_pipe: int = 0 self._bbr_full_bandwidth: int = 0 self._bbr_full_bandwidth_count: int = 0 self._bbr_cwnd: int = 0 self._bbr_nominal_bandwidth: int = 0 self._bbr_bandwidth: int = 0 self._bbr_pacing_gain: float = 0 self._bbr_cwnd_gain: float = 0 self._bbr_pacing_rate: float = 0 self._bbr_cwnd_rate: float = 0 self._bbr_rate: float = 0 self._bbr_rateinv: float = 0 # 1 / bbr_rate self._bbr_target_cwnd: int = 0 self._bbr_bytes_lost: int = 0 self._bbr_prior_inflight: int = 0 self._bbr_now_inflight: int = 0 self._bbr_prior_delivered: int = 0 self._bbr_prior_time: float = 0 self._bbr_send_elapsed: float = 0 self._bbr_ack_elapsed: float = 0 self._bbr_interval: float = 0 self._bbr_delivered: int = 0 self._bbr_delivery_rate: int = 0 def _setrto(self, rtt: float) -> None: if rtt <= 0: return if self._rtt_measured == 0: self._rtt_measured = 1 self._rtt_smoothed = rtt self._rtt_variance = 0.5 * rtt self._rtt_mdev = 0 self._rtt_mdev_max = 0 self._rtt_nextdecrease = self._now + 2 * rtt if self._packetssent > 1: self._rto = 3 # rfc 6298 paragraph 5.7 return diff = rtt diff -= self._rtt_smoothed self._rtt_smoothed += 0.125 * diff if diff > 0: diff -= self._rtt_mdev else: diff = -diff diff -= self._rtt_mdev # slow down increase of mdev when rtt seems to be decreasing if diff > 0: diff *= 0.125 self._rtt_mdev += 0.25 * diff if self._rtt_mdev > self._rtt_mdev_max: self._rtt_mdev_max = self._rtt_mdev if self._rtt_mdev > self._rtt_variance: self._rtt_variance = self._rtt_mdev self._rto = self._rtt_smoothed + 4 * self._rtt_variance + 0.000001 if self._now >= self._rtt_nextdecrease: if self._rtt_mdev_max < self._rtt_variance: self._rtt_variance -= 0.25 * ( self._rtt_variance - self._rtt_mdev_max ) self._rtt_mdev_max = 0 self._rtt_nextdecrease = self._now + self._rtt_smoothed # rfc 6298 says "should be rounded up to 1 second" but linux normally rounds # up to 0.2 seconds self._rto = max(self._rto, 0.2) # #### BBR congestion control #### # def _bbr_enterprobertt(self) -> None: self._bbr_state = BBR_PROBERTT self._bbr_pacing_gain = 1 self._bbr_cwnd_gain = 1 def _bbr_enterstartup(self) -> None: self._bbr_state = BBR_STARTUP self._bbr_pacing_gain = 2.88539 self._bbr_cwnd_gain = 2.88539 def _bbr_enterdrain(self) -> None: self._bbr_state = BBR_DRAIN self._bbr_pacing_gain = 0.34657359 self._bbr_cwnd_gain = 2.88539 def _bbr_advancecyclephase(self) -> None: self._bbr_cycle_stamp = self._now self._bbr_cycle_index = (self._bbr_cycle_index + 1) & 7 self._bbr_pacing_gain = bbr_pacing_gain_cycle[self._bbr_cycle_index] def _bbr_enterprobebandwidth(self) -> None: self._bbr_state = BBR_PROBEBANDWIDTH self._bbr_pacing_gain = 1 self._bbr_cwnd_gain = 2 self._bbr_cycle_index = choice(range(1, 8)) self._bbr_advancecyclephase() def _bbrinit(self) -> None: if self._bbrinit_happened: return self._bbrinit_happened = 1 self._bbr_bandwidthfilter.reset(0, 0) self._bbr_rtprop = self._rtt_smoothed if self._rtt_smoothed == 0: self._bbr_rtprop = 86400 self._bbr_rtprop_stamp = self._now self._bbr_probe_rtt_done_stamp = 0 self._bbr_probe_rtt_round_done = 0 self._bbr_packet_conservation = 0 self._bbr_prior_cwnd = 0 self._bbr_idle_restart = 0 self._bbr_next_round_delivered = 0 self._bbr_round_start = 0 self._bbr_round_count = 0 self._bbr_filled_pipe = 0 self._bbr_full_bandwidth = 0 self._bbr_full_bandwidth_count = 0 self._bbr_cwnd = INITCWND if self._rtt_smoothed: self._bbr_nominal_bandwidth = int(INITCWND / self._rtt_smoothed) else: self._bbr_nominal_bandwidth = int(INITCWND / 0.001) self._bbr_enterstartup() self._bbr_pacing_rate = ( self._bbr_pacing_gain * self._bbr_nominal_bandwidth ) self._bbr_cwnd_rate = self._bbr_cwnd / self._rtt_smoothed self._bbr_rate = self._bbr_cwnd_rate if self._bbr_rate > self._bbr_pacing_rate: self._bbr_rate = self._bbr_pacing_rate self._bbr_rateinv = 1 / self._bbr_rate def _bbrinflight(self, gain: float) -> float: if self._bbr_rtprop == 86400: return INITCWND return 0.99 * gain * self._bbr_bandwidth * self._bbr_rtprop + 4096 def _bbr_checkcyclephase(self) -> None: if self._bbr_state != BBR_PROBEBANDWIDTH: return is_full_length: bool = ( self._now - self._bbr_cycle_stamp ) > self._bbr_rtprop if self._bbr_pacing_gain == 1: if not is_full_length: return elif self._bbr_pacing_gain > 1: if not is_full_length: return if self._bbr_bytes_lost == 0: if self._bbr_prior_inflight < self._bbrinflight( self._bbr_pacing_gain ): return else: if not is_full_length: if self._bbr_prior_inflight > self._bbrinflight(1): return self._bbr_advancecyclephase() def _bbr_checkfullpipe(self) -> None: if not self._bbr_filled_pipe: return if not self._bbr_round_start: return if self._bbr_bandwidth >= self._bbr_full_bandwidth * 1.25: self._bbr_full_bandwidth = self._bbr_bandwidth self._bbr_full_bandwidth_count = 0 return self._bbr_full_bandwidth_count += 1 if self._bbr_full_bandwidth_count >= 3: self._bbr_filled_pipe = 1 # ### BBR delivery-rate estimation ### # def _bbrack(self, p: PacingPacket, packetrtt: float) -> None: bytes_delivered: int = p.len self._bbrinit() self._bbr_bytes_lost = ( 0 # XXX: see above regarding negative acknowledgments ) self._bbr_prior_inflight = self._bytesinflight self._bbr_now_inflight = self._bbr_prior_inflight - bytes_delivered self._netbytesdelivered += bytes_delivered self._netbytesdelivered_time = self._now if p.save_netbytesdelivered > self._bbr_prior_delivered: self._bbr_prior_delivered = p.save_netbytesdelivered self._bbr_prior_time = p.save_netbytesdelivered_time self._bbr_send_elapsed = p.transmissiontime - p.first_sent_time self._bbr_ack_elapsed = ( self._netbytesdelivered_time - self._bbr_prior_time ) self._first_sent_time = p.transmissiontime if self._bbr_prior_time != 0: self._bbr_interval = self._bbr_send_elapsed if self._bbr_ack_elapsed > self._bbr_interval: self._bbr_interval = self._bbr_ack_elapsed self._bbr_delivered = ( self._netbytesdelivered - self._bbr_prior_delivered ) if ( self._bbr_interval < self._rtt_smoothed ): # /* XXX: replace with bbr_minrtt */ self._bbr_interval = -1 elif self._bbr_interval > 0: self._bbr_delivery_rate = int( self._bbr_delivered / self._bbr_interval ) self._bbr_delivered += bytes_delivered if p.save_netbytesdelivered >= self._bbr_next_round_delivered: self._bbr_next_round_delivered = self._bbr_delivered self._bbr_round_count += 1 self._bbr_round_start = 1 else: self._bbr_round_start = 0 if self._bbr_delivery_rate >= self._bbr_bandwidth: self._bbr_bandwidthfilter.running_max( 10, self._bbr_round_count, self._bbr_delivery_rate ) self._bbr_bandwidth = int(self._bbr_bandwidthfilter.get()) self._bbr_checkcyclephase() self._bbr_checkfullpipe() if self._bbr_state == BBR_STARTUP and self._bbr_filled_pipe: self._bbr_enterdrain() if ( self._bbr_state == BBR_DRAIN and self._bbr_now_inflight <= self._bbrinflight(1) ): self._bbr_enterprobebandwidth() self._bbr_rtprop_expired = self._now > self._bbr_rtprop_stamp + 10 if packetrtt >= 0: if packetrtt <= self._bbr_rtprop or self._bbr_rtprop_expired: self._bbr_rtprop = packetrtt self._bbr_rtprop_stamp = self._now if self._bbr_state != BBR_PROBERTT: if self._bbr_rtprop_expired: if not self._bbr_idle_restart: self._bbr_enterprobertt() # XXX: do this only if not in lossrecovery self._bbr_prior_cwnd = self._bbr_cwnd self._bbr_probe_rtt_done_stamp = 0 if self._bbr_state == BBR_PROBERTT: if ( self._bbr_probe_rtt_done_stamp == 0 and self._bbr_now_inflight <= MINCWND ): self._bbr_probe_rtt_done_stamp = self._now + 0.2 self._bbr_probe_rtt_round_done = 0 self._bbr_next_round_delivered = self._bbr_delivered elif self._bbr_probe_rtt_done_stamp: if self._bbr_round_start: self._bbr_probe_rtt_round_done = 1 if self._bbr_probe_rtt_round_done: if self._now > self._bbr_probe_rtt_done_stamp: self._bbr_rtprop_stamp = self._now if self._bbr_cwnd < self._bbr_prior_cwnd: self._bbr_cwnd = self._bbr_prior_cwnd if self._bbr_filled_pipe: self._bbr_enterprobebandwidth() else: self._bbr_enterstartup() self._bbr_idle_restart = 0 rate = self._bbr_pacing_gain * self._bbr_bandwidth if self._bbr_filled_pipe or rate > self._bbr_pacing_rate: self._bbr_pacing_rate = rate self._bbr_target_cwnd = int(self._bbrinflight(self._bbr_cwnd_gain)) if self._bbr_bytes_lost > 0: self._bbr_cwnd -= self._bbr_bytes_lost self._bbr_cwnd = max(self._bbr_cwnd, 1600) if not self._bbr_packet_conservation: if self._bbr_cwnd < self._bbr_now_inflight + bytes_delivered: self._bbr_cwnd = self._bbr_now_inflight + bytes_delivered if self._bbr_filled_pipe: self._bbr_cwnd += bytes_delivered if self._bbr_cwnd > self._bbr_target_cwnd: self._bbr_cwnd = self._bbr_target_cwnd elif ( self._bbr_cwnd < self._bbr_target_cwnd or self._bbr_delivered < INITCWND ): self._bbr_cwnd += bytes_delivered self._bbr_cwnd = max(self._bbr_cwnd, MINCWND) if self._bbr_state == BBR_PROBERTT: self._bbr_cwnd = max(self._bbr_cwnd, MINCWND) self._bbr_cwnd_rate = self._bbr_cwnd / self._rtt_smoothed self._bbr_rate = self._bbr_cwnd_rate if self._bbr_rate > self._bbr_pacing_rate: self._bbr_rate = self._bbr_pacing_rate self._bbr_rateinv = 1 / self._bbr_rate # #### pacing #### # def _pacing_rememberpacket(self, numbytes: int) -> None: if not self._pacing_when or ( self._now - self._pacing_when > 0.5 * self._rtt_smoothed ): self._pacing_when = self._now self._pacing_netbytes = 0 return self._pacing_netbytes += int( self._bbr_rate * (self._now - self._pacing_when) ) self._pacing_when = self._now self._pacing_netbytes -= numbytes # #### something happened with a packet #### # def now_update(self) -> None: self._now = monotonic() def transmitted(self, p: PacingPacket) -> None: firsttransmission = p.transmissions == 0 p.transmissions += 1 p.transmissiontime = self._now if self._packetssent == 0 or self._now - self._lastsending > 1: # XXX: consider more serious reset of state self._netbytesdelivered_time = self._now self._first_sent_time = self._now p.save_netbytesdelivered = self._netbytesdelivered p.save_netbytesdelivered_time = self._netbytesdelivered_time p.first_sent_time = self._first_sent_time self._packetssent += 1 self._lastsending = self._now self._pacing_rememberpacket(p.len) if firsttransmission: self._bytesinflight += p.len else: self._rto *= 2 # rfc 6298 paragraph 5.5 self._rto = min(self._rto, 120) def acknowledged(self, p: PacingPacket) -> None: if p.acknowledged: return p.acknowledged = 1 self._packetsreceived += 1 # karn's algorithm: ignore RTT for retransmitted packets # XXX: transport protocol that can figure out ack for retransmission can reset transmissions, transmissiontime if p.transmissions == 1: rtt = self._now - p.transmissiontime self._setrto(rtt) self._bbrack(p, rtt) self._bytesinflight -= p.len def whendecongested(self, numbytes: int) -> float: decongest: float if not self._packetsreceived: if not self._packetssent: return 0 # our very first packet; send immediately return ( self._lastsending + 0.5 * self._packetssent - self._now ) # XXX: randomize a bit? if self._bytesinflight >= self._bbr_cwnd: return self._lastsending + self._rto - self._now if self._bbr_rate * self._rtt_smoothed < numbytes: decongest = self._lastsending + self._rtt_smoothed else: numbytes -= self._pacing_netbytes decongest = self._pacing_when + numbytes * self._bbr_rateinv if decongest > self._lastsending + self._rtt_smoothed: decongest = self._lastsending + self._rtt_smoothed return decongest - self._now def whenrto(self, p: PacingPacket) -> float: if p.transmissions: return p.transmissiontime + self._rto - self._now return 0 pqconnect-1.2.1/src/pqconnect/peer.py0000644000000000000000000001551214733452565016313 0ustar rootrootfrom enum import Enum from ipaddress import ip_address from typing import Optional, Tuple from .common.constants import COOKIE_PREFIX, KEYPORT, PQCPORT from .log import logger from .tunnel import TunnelSession class PeerState(Enum): """State of a peer connection. NEW: handshake msg sent (client) -> ESTABLISHED handshake msg or cookie received, success (server) -> ESTABLISHED close -> CLOSED ESTABLISHED: transport message received, successfully decrypted -> ALIVE tunnel timeout -> EXPIRED handshake error message received (client) -> ERROR close -> CLOSED ALIVE: tunnel timeout -> EXPIRED close -> CLOSED """ NEW = 1 # No connection attempted yet ESTABLISHED = 2 # Handshake complete but no message received ALIVE = 3 # Message successfully received by peer over channel EXPIRED = 4 # TunnelSession timed out, ready to remove CLOSED = 5 # Closed, ready to remove ERROR = 6 # Error class Peer: """A Peer object holds and modifies the state of a given connection with a remote peer and calls the cryptographic functions for traffic with that peer. """ def __init__( self, external_ip: str, internal_ip: str, pkh: bytes = b"", cname: str = "", mceliece_pk: bytes = b"", x25519_pk: bytes = b"", port: Optional[int] = None, keyport: Optional[int] = None, ): self._tunnel: Optional[TunnelSession] = None # validate ip addresses try: ip_address(external_ip) ip_address(internal_ip) self._external_ip = external_ip # Encapsulating (outer) IP self._internal_ip = internal_ip # Encapsulated (inner) IP except ValueError as e: raise e if pkh and not isinstance(pkh, bytes): raise TypeError("pkh must be a byte string") self._pkh = pkh # public key hash (if they are a server) if cname and not isinstance(cname, str): raise TypeError("cname must be a string") self._cname = cname self._mceliece_pk = mceliece_pk self._x25519_pk = x25519_pk self._pqcport = port # Encapsulating header port self._keyport = keyport self._cookie = b"" # Initialize the state of the peer self._state = PeerState.NEW self._tid: bytes = b"" def get_cname(self) -> str: """Returns the peer cname""" return self._cname def get_pkh(self) -> bytes: """Returns the public key hash""" return self._pkh def get_tid(self) -> bytes: """Returns the tunnel ID of the tunnel associated with this peer.""" if not self._tid: raise AttributeError("Peer has no tid") return self._tid def get_state(self) -> PeerState: return self._state def is_alive(self) -> bool: """Returns True if the peer has an active connection""" if not self._tunnel: return False return self._state == PeerState.NEW or self._tunnel.is_alive() def error(self) -> None: """Sets the state to ERROR""" self._state = PeerState.ERROR def last_used(self) -> float: """Returns the last timestamp that we sent traffic to or received traffic from the peer. If no connection has been established with the peer it returns 0. This allows to sort peers by their most recent activity. """ if self._tunnel: return self._tunnel.get_most_recent_timestamp() else: return 0 def set_tunnel(self, tunnel: TunnelSession) -> None: """Associate the tunnel with this peer and update state.""" self._tunnel = tunnel self._tid = tunnel.get_tid() self._state = PeerState.ESTABLISHED # external IP address def get_external_ip(self) -> str: """Returns this peer's external IP address""" return self._external_ip def set_external_ip(self, ip: str) -> None: """Sets the external ip address to `ip`""" if not isinstance(ip, str): raise ValueError("ip must be a string") self._external_ip = ip # internal IP address def get_internal_ip(self) -> str: """Returns this peer's internal IP address""" return self._internal_ip # pqcport def get_pqcport(self) -> Optional[int]: """Returns this peer's port number""" return self._pqcport def set_pqcport(self, port: int) -> None: """Sets this peer's port number""" if not (isinstance(port, int) and port in range(1 << 16)): raise ValueError("port is not a valid port number") self._pqcport = port # keyport def get_keyport(self) -> Optional[int]: return self._keyport def set_keyport(self, port: int) -> None: if not (isinstance(port, int) and port in range(1 << 16)): raise ValueError("port is not a valid port number") self._keyport = port def encrypt(self, pkt: bytes) -> bytes: """Encrypts pkt under the existing tunnel for this peer. If we have a cookie for this peer, the cookie is pre-pended to the ciphertext """ cookie = b"" if not self._tunnel: raise Exception("Cannot encrypt to peer. No tunnel exists") # include cookie if we have one if self._cookie: cookie = self._cookie self._cookie = b"" return cookie + self._tunnel.tunnel_send(pkt) def decrypt(self, pkt: bytes) -> bytes: """Decrypts pkt under the existing tunnel for this peer. Raises an exception if no connection exists. Returns empty byte string and logs an error if decryption fails. If the message is a cookie then the cookie is stored. """ if not self._tunnel: raise Exception("Cannot decrypt from peer. No tunnel exists") if not self._tunnel.is_alive(): self._state = PeerState.EXPIRED return b"" try: msg = self._tunnel.tunnel_recv(pkt) except Exception as e: logger.exception(f"Decryption failed: {e}") return b"" self._state = PeerState.ALIVE # Set cookie for peer if sent by the server (this will only happen on # the client side, since only the server encrypts the cookie to the # client. The client sends back the cookie outside the tunnel, since at # that point the server has no decryption keys) if msg[: len(COOKIE_PREFIX)] == COOKIE_PREFIX: self._cookie = msg return b"" else: return msg def close(self) -> None: """Sets the state to CLOSED and closes the tunnel object if it exists.""" if self._tunnel: self._tunnel.close() self._state = PeerState.CLOSED pqconnect-1.2.1/src/pqconnect/pqcclient.py0000644000000000000000000005450414733452565017346 0ustar rootrootfrom multiprocessing.connection import Connection from multiprocessing.synchronize import Event from socket import AF_INET, SO_RCVBUF, SOCK_DGRAM, SOL_SOCKET, socket, timeout from threading import Thread from time import time from typing import Dict, List, Optional, Tuple from dns import resolver from dns.rdtypes.ANY.TXT import TXT from py25519 import dh, dh_keypair from scapy.all import DNSRR, IP, UDP, dnstypes from SecureString import clearmem from pqconnect.common.constants import ( A_RECORD, INITIATION_MSG, NUM_PREKEYS, SEG_LEN, ) from pqconnect.common.crypto import NLEN, ekem, h, secret_box, skem from pqconnect.common.crypto import stream_kdf as kdf from pqconnect.common.util import base32_decode, base32_encode from pqconnect.dns_parse import parse_pq1_record from pqconnect.keys import PKTree from pqconnect.log import logger from pqconnect.pacing.pacing import PacingConnection, PacingPacket from pqconnect.peer import Peer from pqconnect.request import ( EphemeralKeyRequest, EphemeralKeyResponse, KeyResponseHandler, StaticKeyRequest, StaticKeyResponse, ) from pqconnect.request_static_key import request_static_keys from pqconnect.tundevice import TunDevice from pqconnect.tunnel import TunnelSession MAX_FAILS = 10 # TODO add to constants file TIMEOUT_SECONDS = 2 # TODO add to constants file class PQCClient: def __init__( self, port: int, tun_conn: Connection, dns_conn: Connection, end_cond: Event, dev_name: str = "pqc0", host_ip: Optional[str] = None, ) -> None: self._device = TunDevice( port, tun_conn=tun_conn, dev_name=dev_name, host_ip=host_ip ) self._dns_conn = dns_conn # {pkh: bytes {"ts": int, "mceliece": list, "x25519": bytes}} self._prekeys: dict = dict() self._end_cond = end_cond self._tun_thread = Thread(target=self._device.start) def generate_prekeys( self, pkh: bytes, mceliece_pk: bytes, x25519_pk: bytes, timestamp: Optional[int] = None, ) -> bool: """generates a cache of McEliece ciphertexts to be used in future handshakes with the peer Returns: False if we already have a non-empty cache for the peer True if new keys were generated """ if not timestamp: now = int(time()) else: now = timestamp # type checks if not ( isinstance(pkh, bytes) and isinstance(mceliece_pk, bytes) and isinstance(x25519_pk, bytes) ): raise TypeError # This shouldn't be called when there already pre-computed keys, but # check anyway if pkh in self._prekeys and len(self._prekeys[pkh]["mceliece"]) > 0: return False self._prekeys[pkh] = {} self._prekeys[pkh]["ts"] = now self._prekeys[pkh]["x25519"] = x25519_pk self._prekeys[pkh]["mceliece"] = [] for _ in range(NUM_PREKEYS): self._prekeys[pkh]["mceliece"].append(skem.enc(mceliece_pk)) return True def _get_pk_hash(self, p: IP) -> tuple: """Parse public key hash from DNS response and return the raw 32-byte hash """ if DNSRR in p: # early reject if not a DNS response # iterate through answers for i in range(p.ancount): if DNSRR in p.an[i]: if p.an[i][DNSRR].type == A_RECORD: try: name = p.an[i][DNSRR].rrname.decode("utf-8") parsed_vals = parse_pq1_record(name) if len(parsed_vals) > 0: return parsed_vals except Exception: logger.exception( "Could not decode public key hash: " f"\x1b[33;20m{name.hex()}\x1b[0m" ) continue return () def _get_addrs(self, p: IP) -> list[str]: """Collect all IPv4 addresses from DNS answer records""" addrs = [] if DNSRR in p: ancount = p.ancount if ( ancount > 25 ): # https://stackoverflow.com/questions/6794926/how-many-a-records-can-fit-in-a-single-dns-response raise Exception( "Suspicious packet. Too many response records." ) for i in range(ancount): if DNSRR in p.an[i] and p.an[i][DNSRR].type == A_RECORD: addrs.append(p.an[i][DNSRR].rdata) return addrs @staticmethod def _get_domain_name(p: IP) -> str: """Takes a scapy packet object and returns the rdata in the CNAME record """ if DNSRR not in p: raise ValueError("packet has no response record") for i in range(p.ancount): if DNSRR in p.an[i] and dnstypes[p.an[i][DNSRR].type] == "A": return p.an[i][DNSRR].rrname.decode() return "" def _dns_handle(self, pkt: bytes) -> Tuple[bytes, Optional[Peer]]: """Checks incoming DNS packets from the netfilterqueue proxy for PQConnect PK hashes. If a hash is found, then the peer with this public key is returned if known, or a new peer is created and it is assigned a new internal IP. """ p = IP(pkt) if DNSRR not in p: return (pkt, None) # Ignore TXT requests, as these are handled by the handshake thread directly for i in range(p.ancount): if DNSRR in p.an[i] and dnstypes[p.an[i][DNSRR].type] == "TXT": return (pkt, None) dns_vals: tuple = self._get_pk_hash(p) pqcport = None keyport = None if len(dns_vals) == 1: (pkhash,) = dns_vals elif len(dns_vals) == 3: (pkhash, pqcport, keyport) = dns_vals else: return (pkt, None) # Internal IP will either be created or retrieved from existing peer int_ip = None # Check if peer exists try: peer = self._device.get_peer_by_pkh(pkhash) except Exception: peer = None # Get the external IP try: ext_addrs = self._get_addrs(p) if len(ext_addrs) < 1: logger.warning( "DNS response contains public key hash" f" \x1b[33;20m{pkhash!r}\x1b[0m but no A record." ) return pkt, None # TODO if there are multiple IPs we could try to ping them all and # connect to whichever responds first, i.e. fastest. Also good to # handle situations where there are both A and AAAA records if len(ext_addrs) > 1: logger.log(9, "Multiple A records found. Using the first one.") # Use first IP address ext_ip = ext_addrs[0] # If the client is running a stub resolver or local dns cache like # systemd-resolved or dnsmasq then we may see the same DNS response # twice. One coming from the network to the stub resolver, then the # same response arriving from the stub resolver to the application # that requested name resolution. PQConnect connections should not # be initiated from a DNS response that has already had its records # translated to an internal IP. if self._device.is_internal_ip_address(ext_ip): return (pkt, None) except Exception: return (pkt, None) if not peer: # Create one int_ip = ( self._device.get_next_ip() ) # TODO likely ToCToU issue!. self.device.next_ip should be # incremented already. cname: str = self._get_domain_name(p) peer = Peer(ext_ip, int_ip, pkh=pkhash, cname=cname) # peer exists now, get its internal IP if not int_ip: int_ip = peer.get_internal_ip() # if we know the peer's keyport and pqcport, assign/update them if pqcport: peer.set_pqcport(pqcport) if keyport: peer.set_keyport(keyport) # replace all DNS answer records with internal ip for i in range(p.ancount): if DNSRR in p.an[i] and p.an[i][DNSRR].type == A_RECORD: p.an[i][DNSRR].rdata = int_ip p.an[i][DNSRR].ttl = 0 # recompute checksums del p[IP].len del p[IP].chksum del p[UDP].len del p[UDP].chksum p.clear_cache() return bytes(p), peer def _has_prekeys(self, peer: Peer) -> bool: """Returns True iff peer has known static x25519 pk and a non-zero number of mceliece ciphertexts """ try: pkh = peer.get_pkh() return ( pkh in self._prekeys and len(self._prekeys[pkh]["mceliece"]) > 0 ) except Exception as e: logger.exception(e) return False def connect(self, peer: Peer) -> bool: """ Connect to the given peer. If there is an active connection with the peer, return True. Dispatch a connection handler to create a new connection. If connection is successful (i.e. if we sent a handshake message), then return True, otherwise False (most likely because public keys couldn't be obtained/verified) """ if peer.is_alive(): logger.log( 9, f"DNS reply for existing peer: {peer.get_internal_ip()}" ) return True # Attempt a new connection pkh = peer.get_pkh() logger.info( f"Connecting to peer at {peer.get_external_ip()} " f"with pk hash {base32_encode(pkh)}" ) # Send handshake using a pre-computed ciphertext if available if self._has_prekeys(peer): mceliece_ct = self._prekeys[pkh]["mceliece"].pop() x25519_pk = self._prekeys[pkh]["x25519"] else: mceliece_ct, x25519_pk = [b"", b""], b"" ctx = PQCClientConnectionHandler( peer, self._device, self, mceliece_ct=mceliece_ct, s_x25519_r=x25519_pk, ) # start handshake thread and return ctx.start() return True def start(self) -> None: """Starts two listeners as unprivileged user. One to handle packets forwarded by the TUN device, another to handle packets forwaded by the DNS proxy. The modified DNS responses are sent back to the requesting process after a connection has been initiated, so that subsequent DNS responses for this query give the same internal IP. """ logger.info(f"Listening on port {self._device.get_pqcport()}") self._tun_thread.start() try: # Monitor incoming DNS packets from proxy. while not self._end_cond.is_set(): if self._dns_conn.poll(0.1): # timeout so we loop back pkt = self._dns_conn.recv_bytes() pkt, peer = self._dns_handle(pkt) self._dns_conn.send_bytes(pkt) if peer: # Try to connect to peer. Add peer if successful TODO this # should happen asynchronously so that connection issues do # not interfere with future DNS request handles if not self.connect(peer): logger.error("Could not connect to peer") # TODO except Exception: pass def stop(self) -> None: self._end_cond.set() self._device.close() if self._tun_thread.is_alive(): self._tun_thread.join() class PQCClientConnectionHandler(Thread): """This class handles a new connection, obtaining public keys and performing a 0RTT handshake. """ def __init__( self, peer: Peer, device: TunDevice, client: PQCClient, mceliece_ct: tuple[bytes, bytes] = (b"", b""), s_x25519_r: bytes = b"", ): super().__init__() self._transport = socket(AF_INET, SOCK_DGRAM) self._transport.settimeout(TIMEOUT_SECONDS) self._peer = peer self._device = device self._client = client self._pkh: bytes = peer.get_pkh() if not self._pkh: raise ValueError("Peer must have a public key hash") self._mceliece_ct: tuple[bytes, bytes] = mceliece_ct self._s_x25519_r: bytes = s_x25519_r self._s_mceliece_r: bytes = b"" self._e_sntrup_r: bytes = b"" self._e_x25519_r: bytes = b"" self._e_x25519_i: bytes = b"" self._e_x25519sk_i: bytes = b"" self._pktree: PKTree = PKTree() self._pktree.insert_node(0, 0, self._pkh) def _resolve_keyserver_address(self) -> None: """Makes a DNS TXT query to get the keyserver ip and port values""" if not self._peer.get_cname(): raise Exception("Missing server domain name") query_name = "ks." + self._peer.get_cname() answer: resolver.Answer = resolver.resolve(query_name, "TXT") try: if not answer.rrset: raise Exception("Text record does not exist") response_data: TXT = answer.rrset.pop() # response_data.to_text() should look like '"ip=1.2.3.4;p=12345"' ip, port = [ r.split("=")[1].strip() for r in response_data.to_text().replace('"', "").split(";") ] self._peer.set_keyport(int(port)) except Exception: raise ValueError(f"Keyserver DNS record is misconfigured") def _resolve_pqc_port(self) -> None: """Makes a DNS TXT query to get pqc port. Defaults to PQCPORT if not found. """ if not self._peer.get_cname(): raise Exception("Missing server domain name") port = None try: answer: resolver.Answer = resolver.resolve( self._peer.get_cname(), "TXT" ) if not answer.rrset: raise Exception for resp in answer.rrset: # rr.to_text() should look like '"p=54321"' # remove any quotation marks and split into key, value # ignore any responses keyval = resp.to_text().replace('"', "").split("=") if len(keyval) != 2 or keyval[0].strip() != "p": continue port = int(keyval[1]) self._peer.set_pqcport(port) break if not port: raise Exception("port number not found in RR set") except (resolver.NXDOMAIN, Exception) as e: raise ValueError(f"PQConnect port TXT record does not exist: {e}") def _send_static_key_request(self, depth: int, pos: int) -> bool: """Send static key request to peer""" try: req = StaticKeyRequest(depth=depth, pos=pos) self._transport.sendto( req.payload, (self._peer.get_external_ip(), self._peer.get_keyport()), ) return True except Exception as e: logger.exception(e) return False def _send_ephemeral_key_request(self) -> bool: """Send ephemeral key request to peer""" try: req = EphemeralKeyRequest().payload self._transport.sendto( req, (self._peer.get_external_ip(), self._peer.get_keyport()) ) return True except Exception as e: logger.exception(e) return False def _request_static_keys_paced(self) -> bool: """Uses Pacing Connection to request static keys while avoiding congestion""" ip = self._peer.get_external_ip() port = self._peer.get_keyport() if not ip or not port: return False logger.debug( f"Requesting static keys from {(self._peer.get_external_ip(), self._peer.get_keyport())}" ) request_static_keys(self._pktree, ip, port) if not self._pktree.is_complete(): return False self._s_mceliece_r = self._pktree.get_pqpk() self._s_x25519_r = self._pktree.get_npqpk() return True def _request_ephemeral_keys(self) -> bool: """Requests the current ephemeral keys from the server. Returns True if received """ for _ in range(MAX_FAILS): logger.debug( f"Requesting ephemeral keys for peer at {self._peer.get_external_ip()}" ) self._send_ephemeral_key_request() # There may be extra static key packets still arriving, so we just # receive packets until we have an ephemeral key or the socket # times out resp = None while True: try: data, addr = self._transport.recvfrom(4096) resp = KeyResponseHandler(data).response() if not isinstance(resp, EphemeralKeyResponse): continue break except (TimeoutError, timeout): logger.exception( "Client connection handler socket timed out" ) break try: if resp and isinstance(resp, EphemeralKeyResponse): self._e_sntrup_r = resp.pqpk self._e_x25519_r = resp.npqpk return True else: continue except Exception as e: continue return False def initiate_handshake_0rtt( self, ) -> tuple[bytes, bytes, bytes, bytes, bytes, TunnelSession]: #### k0 # Encapsulate k0 against responder's long term McEliece pk # and mix k0 into cipherstate c0, self.cipher_state = self._mceliece_ct if not (c0 and self.cipher_state): raise Exception("No pre-keys found for this peer") # Store c0 in handshake_state self.handshake_state = c0 # Generate ephemeral ECDH keys self._e_x25519_i, self._e_x25519sk_i = dh_keypair() # box epkIx25519 c1, tag1 = secret_box( self.cipher_state, b"\x00" * NLEN, self._e_x25519_i, self.handshake_state, ) self.handshake_state = h(self.handshake_state + c1 + tag1) #### k1 k1 = dh(self._s_x25519_r, self._e_x25519sk_i) (self.cipher_state,) = kdf( 1, self.cipher_state, k1, ) clearmem(k1) #### k2 k2 = dh(self._e_x25519_r, self._e_x25519sk_i) (self.cipher_state,) = kdf( 1, self.cipher_state, k2, ) clearmem(k2) #### k3 c2, k3 = ekem.enc(self._e_sntrup_r) c3, tag3 = secret_box( self.cipher_state, b"\x00" * NLEN, c2, self.handshake_state ) (self.cipher_state,) = kdf(1, self.cipher_state, k3) clearmem(k3) self.handshake_state = h(self.handshake_state + c3 + tag3) #### tid and final keys tid, ti, tr = kdf(3, self.cipher_state, self.handshake_state) return c0, c1, tag1, c3, tag3, TunnelSession(tid, ti, tr) def run(self) -> None: try: # Resolve server port if not self._peer.get_pqcport(): self._resolve_pqc_port() # Resolve keyserver address if not self._peer.get_keyport(): self._resolve_keyserver_address() except Exception as e: logger.warn(f"Error resolving DNS information") return # If we do not have a pre-computed mceliece ciphertext and the x25519 # public key, we request the static keys and generate a mceliece # ciphertext c, k = self._mceliece_ct if not (c and k): try: if not self._request_static_keys_paced(): logger.debug( f"Failed to obtain static keys for peer at {self._peer.get_external_ip()}" ) return # abort logger.debug( f"Obtained static keys from {self._peer.get_external_ip()}" ) self._mceliece_ct = skem.enc(self._s_mceliece_r) except Exception as e: logger.exception( f"Failed to obtain static keys for peer at {self._peer.get_external_ip()}: {e}" ) return # abort # Request ephemeral keys from the server try: if not self._request_ephemeral_keys(): logger.debug( f"Failed to obtain ephemeral keys for peer at {self._peer.get_external_ip()}" ) return # abort logger.debug( f"Obtained ephemeral keys from {self._peer.get_external_ip()}" ) except Exception as e: logger.exception( f"Failed to obtain ephemeral keys for peer at {self._peer.get_external_ip()}: {e}" ) return # abort # Compute and send the handshake message c0, c1, tag1, c3, tag3, session = self.initiate_handshake_0rtt() self._transport.sendto( INITIATION_MSG + b"".join([c0, c1, tag1, c3, tag3]), (self._peer.get_external_ip(), self._peer.get_pqcport()), ) logger.debug( f"Sent handshake message to {self._peer.get_external_ip()}" ) # Add peer self._peer.set_tunnel(session) self._device.add_peer(self._peer) # If we fetched the static keys at the start of this method, we should # precompute some mceliece ciphertexts and keys for the next time we # connect if self._s_mceliece_r and self._s_x25519_r: self._client.generate_prekeys( self._pkh, self._s_mceliece_r, self._s_x25519_r ) self._transport.close() pqconnect-1.2.1/src/pqconnect/pqcserver.py0000644000000000000000000003255414733452565017377 0ustar rootrootimport importlib.metadata from ipaddress import ip_address from multiprocessing.connection import Connection from threading import Event, Lock, Thread from time import monotonic as time from typing import Dict, Optional, Tuple from pqconnect.common.constants import ( EPOCH_DURATION_SECONDS, EPOCH_TIMEOUT_SECONDS, INITIATION_MSG, PQCPORT, ) from pqconnect.common.crypto import ( NLEN, TAGLEN, dh, ekem, h, secret_unbox, skem, ) from pqconnect.common.crypto import stream_kdf as kdf from pqconnect.cookie.cookiemanager import CookieManager from pqconnect.keystore import EphemeralPrivateKey, EphemeralPrivateKeystore from pqconnect.log import logger from pqconnect.peer import Peer from pqconnect.tundevice import TunDevice from pqconnect.tunnel import TunnelSession class ReplayError(Exception): """Raised when a replayed handshake message is detected""" class HandshakeError(Exception): """Raised when an invalid Handshake message is received""" class PQCServer: """The PQCServer class handles handshake requests from clients. Attributes __________ mceliece_sk: bytes The long term mceliece secret key x25519_sk: bytes The long term x25519 secret key session_key: bytes The long term symmetric session ticket encryption key keystore: EphemeralPrivateKeystore See class documentation version: str Current version device: TunDevice See class documentation """ def __init__( self, mceliece_path: str, x25519_path: str, session_key_path: str, port: int, tun_conn: Connection, dev_name: str = "pqc0", host_ip: Optional[str] = None, ): try: logger.info("Starting PQConnect Server") logger.info("Loading static keys") with open(mceliece_path, "rb") as mceliece_sk: self.mceliece_sk = mceliece_sk.read() with open(x25519_path, "rb") as x25519_sk: self.x25519_sk = x25519_sk.read() with open(session_key_path, "rb") as session_key: self.session_key = session_key.read() logger.info("Static keys found") except FileNotFoundError: raise FileNotFoundError("Could not load static keys. Aborting...") self._keystore: Optional[EphemeralPrivateKeystore] = None self.version = importlib.metadata.version("pqconnect") # Condition variable to join threads self._end_cond = Event() # mutex for seen_mceliece_cts self._mut = Lock() self._cookie_manager = CookieManager(self.session_key) # To avoid handshake message replays, we keep a list of seen McEliece # ciphertexts from handshakes sent during the current ephemeral key # validity period. This gets cleaned periodically by a cleanup routine. self._seen_mceliece_cts: Dict[int, bytes] = dict() # XXX: checking for seen ct's means scanning whole dict. Probably # should reverse this, so the forgetting thread scans all entries every # 30 seconds to remove ones with old timestamps, and checking for # replays is O(1) self._forget_old_mceliece_cts_thread = Thread( target=self._forget_old_mceliece_cts ) self._forget_old_mceliece_cts_thread.start() self._device = TunDevice( port, server=self, cookie_manager=self._cookie_manager, tun_conn=tun_conn, dev_name=dev_name, host_ip=host_ip, ) self._device_thread = Thread(target=self._device.start) def set_keystore(self, keystore: EphemeralPrivateKeystore) -> None: """Adds the keys in keystore to the server's keystore""" # Since the lifetime of keys in the keystore will overlap the # transition from one keystore to the next, we don't simply replace the # keystore if it exists. We instead add new keys to the existing store. # This gets called from a different thread. However, Python native # structures are thread-safe, so we don't need to implement a lock # here. if not self._keystore: self._keystore = keystore self._keystore.start() else: self._keystore.merge(keystore) logger.debug("Keystore set") def complete_handshake(self, packet: bytes, addr: Tuple[str, int]) -> None: """""" if not isinstance(packet, bytes): raise TypeError("Invalid packet") if not ( isinstance(addr, tuple) and isinstance(addr[0], str) and isinstance(addr[1], int) ): raise TypeError if not self._keystore: raise Exception("No keystore has been set") # Get current secret keys try: keys = self._keystore.get_unexpired_keys() if len(keys) == 0 or not all( [isinstance(k, EphemeralPrivateKey) for k in keys] ): raise Exception("No ephemeral keys available") except Exception: # TODO pass # Create a connection object to complete handshake for eph_key in keys: sntrup, x25519 = eph_key.get_secret_keys() hs = PQCServerHS( self, self.mceliece_sk, self.x25519_sk, sntrup, x25519, packet, addr, ) hs.start() def is_mceliece_ct_seen(self, mceliece_ct: bytes) -> bool: """Returns True if the given mceliece_ct has is in the collection of recently observed ciphertext values """ return mceliece_ct in self._seen_mceliece_cts.values() def remember_mceliece_ct(self, mceliece_ct: bytes) -> None: """Stores a (timestamp,mceliece ciphertext) record from a successful handshake for future replay checks """ with self._mut: if self.is_mceliece_ct_seen(mceliece_ct): raise ValueError( "Cannot add the same mceliece ciphertext twice" ) now = int(time()) self._seen_mceliece_cts[now] = mceliece_ct def _forget_old_mceliece_cts(self) -> None: """Remove old handshake mceliece ciphertexts""" while not self._end_cond.is_set(): self._end_cond.wait(timeout=EPOCH_DURATION_SECONDS) with self._mut: expired = [] old = time() - EPOCH_TIMEOUT_SECONDS for ts in self._seen_mceliece_cts.keys(): if ts <= old: expired.append(ts) for ts in expired: del self._seen_mceliece_cts[ts] def add_new_connection( self, session: TunnelSession, addr: Tuple[str, int] ) -> bool: """Adds a new tunnel session to the monitor""" if session is None or not isinstance(session, TunnelSession): raise TypeError("TunnelSession is invalid") try: ip_address(addr[0]) if not isinstance(addr[1], int) and addr[1] not in range(1 << 16): raise ValueError("Invalid port") except (ValueError, KeyError) as e: raise e internal_ip = self._device.get_next_ip() peer = Peer(addr[0], internal_ip) peer.set_tunnel(session) if addr[1] != PQCPORT: peer.set_pqcport(addr[1]) return self._device.add_peer(peer) def start(self) -> None: """Starts the device monitor as a separate thread""" self._device_thread.start() def close(self) -> None: """Stops all threads and deletes""" logger.debug("Server stopping") self._end_cond.set() logger.log(9, "Joining device thread") if self._device_thread.is_alive(): self._device_thread.join() logger.log(9, "Device thread joined") logger.log(9, "Joining forget_old_mceliece_cts thread") if self._forget_old_mceliece_cts_thread.is_alive(): self._forget_old_mceliece_cts_thread.join() logger.log(9, "forget_old_mceliece_cts thread joined") self._device.close() if self._keystore: self._keystore.close() class PQCServerHS(Thread): """When a new handshake message is received a PQCServerHS object is initiated to parse the handshake messages and complete the handshake. """ def __init__( self, server: PQCServer, s_mceliece_sk: bytes, s_x25519_sk: bytes, e_sntrup_sk: bytes, e_x25519_sk: bytes, pkt: bytes, addr: Tuple[str, int], ): super().__init__() self.server = server self.s_mceliece_sk = s_mceliece_sk self.s_x25519_sk = s_x25519_sk self.e_sntrup_sk = e_sntrup_sk self.e_x25519_sk = e_x25519_sk self.pkt = pkt self.addr = addr self.handshake_state: bytes = b"" self.cipher_state: bytes = b"" def get_handshake_message_values( self, data: bytes ) -> tuple[bytes, bytes, bytes, bytes, bytes]: """Parses handshake message values from raw packet data.""" try: idx = 0 if not data[idx : idx + 2] == INITIATION_MSG: raise ValueError( ( "Message is not a handshake message.", "This shouldn't happen.", ) ) idx += 2 c0 = data[idx : idx + skem.CIPHERTEXTBYTES] idx += skem.CIPHERTEXTBYTES c1 = data[idx : idx + dh.lib25519_dh_PUBLICKEYBYTES] idx += dh.lib25519_dh_PUBLICKEYBYTES tag1 = data[idx : idx + TAGLEN] idx += TAGLEN c3 = data[idx : idx + ekem.clen] idx += ekem.clen tag3 = data[idx : idx + TAGLEN] idx += TAGLEN except IndexError: # we ran out of data during parsing raise IndexError("Handshake message is incomplete.") if len(data) != idx: raise IndexError("Handshake message has incorrect length.") return c0, c1, tag1, c3, tag3 def complete_handshake_0rtt( self, c0: bytes, c1: bytes, tag1: bytes, c3: bytes, tag3: bytes ) -> TunnelSession: """Completes a 0-RTT handshake and if successful, returns a new Tunnel object. """ # Decapsulate c0 store in cipher_state. Note, this will usually return # a value even if the ciphertext is invalid, but the handshake will # fail at the next step. try: self.cipher_state = skem.dec(c0, self.s_mceliece_sk) except Exception: raise ValueError("Failed to decapsulate c0") # Store c0 in handshake_state self.handshake_state = c0 # decrypt c1* try: e_x25519_i = secret_unbox( self.cipher_state, b"\x00" * NLEN, tag1, c1, self.handshake_state, ) except Exception as e: raise ValueError(f"Failed to decrypt client ephemeral key: {e}") self.handshake_state = h(self.handshake_state + c1 + tag1) (self.cipher_state,) = kdf( 1, self.cipher_state, dh.dh(e_x25519_i, self.s_x25519_sk) ) (self.cipher_state,) = kdf( 1, self.cipher_state, dh.dh(e_x25519_i, self.e_x25519_sk) ) try: c2 = secret_unbox( self.cipher_state, b"\x00" * NLEN, tag3, c3, self.handshake_state, ) except Exception as e: raise ValueError(f"Failed to decrypt c3*: {e}") try: k3 = ekem.dec(c2, self.e_sntrup_sk) except Exception as e: raise ValueError(f"Failed to decapsulate c2: {e}") (self.cipher_state,) = kdf(1, self.cipher_state, k3) self.handshake_state = h(self.handshake_state + c3 + tag3) tid, ti, tr = kdf(3, self.cipher_state, self.handshake_state) return TunnelSession(tid, tr, ti) def shake_hands(self, data: bytes) -> TunnelSession: """Given raw packet data returns a Tunnel on success""" try: hs_values = self.get_handshake_message_values(data) logger.debug("Handshake initiation message parsed successfully.") # Check replay mceliece_ct = hs_values[0] if self.server.is_mceliece_ct_seen(mceliece_ct): raise ReplayError("Replay detected") session = self.complete_handshake_0rtt(*hs_values) # Remember mceliece_ct self.server.remember_mceliece_ct(mceliece_ct) logger.info("Handshake succeeded: New tunnel created") return session except IndexError as e: raise HandshakeError(e) def run(self) -> None: """Performs server handshake and establishes a new session if valid""" try: tun = self.shake_hands(self.pkt) except Exception as e: logger.exception(e) return if tun: self.server.add_new_connection(tun, self.addr) logger.debug( f"Tunnel with tID {tun.get_tid().hex()} \ created successfully" ) pqconnect-1.2.1/src/pqconnect/request.py0000644000000000000000000002644414733452565017056 0ustar rootrootfrom struct import pack, unpack_from from typing import Dict, List, Optional, Union from pqconnect.common.constants import ( EPHEMERAL_KEY_REQUEST, EPHEMERAL_KEY_RESPONSE, SEG_LEN, STATIC_KEY_REQUEST, STATIC_KEY_RESPONSE, ) from pqconnect.common.crypto import dh, ekem, skem from pqconnect.keys import PKTree from pqconnect.log import logger _pk, _ = skem.keypair() _ecc, _ = dh.dh_keypair() _mock_tree = PKTree(_pk, _ecc) class UnpackException(Exception): """Raised when a request cannot be unpacked""" class UnexpectedRequestException(Exception): """Raised if an unpacked request has the wrong number of fields or if fields have unexpected values """ class KeyRequest: """Base key request class that handles packing and unpacking of binary data. Correct parsing and validation of untrusted data is left to the subclasses. subclasses can be initialized either with specific attributes or with a blob that is later unpacked """ def __init__( self, msg_type: bytes, payload: Optional[bytes] = None ) -> None: if not isinstance(msg_type, bytes): raise TypeError self._msg_type = msg_type self._payload: Optional[bytes] = payload def _pack_bts(self, bytestrs: list[bytes]) -> None: """Takes a list of bytestrings and sets payload to: msg_type || len(bytestring_0) || bytestring_0 || ... || len(bytestring_n) || bytestring_n Example: >>> from pqconnect.request import KeyRequest >>> req = KeyRequest(bytes.fromhex('0001')) >>> req._pack_bts([b"hello", b"goodbye"]) >>> assert req.payload == bytes.fromhex('00010005') + b'hello' + bytes.fromhex('0007') + b'goodbye' """ if not self._payload is None: raise ValueError if not all(isinstance(bs, bytes) for bs in bytestrs): raise TypeError self._payload = b"".join( [self._msg_type] + [pack("!H", len(bs)) + bs for bs in bytestrs] ) def _pack(self) -> None: raise NotImplementedError def _unpack_bts(self) -> list[bytes]: """Takes a bytestring and parses individual fields from the length prepending each field. Returns them in a list. """ if not self._payload: raise UnpackException bs = self._payload[len(STATIC_KEY_REQUEST) :] ret = [] while bs: # Length value is 2 bytes if len(bs) < 2: raise UnpackException (l,) = unpack_from("!H", bs[:2]) # Length should be >0 if l <= 0: raise UnpackException # There should be at least l bytes left bs = bs[2:] if l > len(bs): raise UnpackException ret.append(bs[:l]) bs = bs[l:] return ret def _unpack(self) -> None: pass @property def payload(self) -> bytes: """Returns the payload of this request as bytes""" if self._payload is None: raise AttributeError return self._payload def __bytes__(self) -> bytes: return self.payload class StaticKeyRequest(KeyRequest): """Request type for static key packet requests Example: >>> from pqconnect.request import StaticKeyRequest, STATIC_KEY_REQUEST >>> req0 = StaticKeyRequest(depth=2, pos=5) >>> req_bytes = req0.payload >>> assert req_bytes[:2] == STATIC_KEY_REQUEST >>> req1 = StaticKeyRequest(payload=req_bytes) >>> assert req1.depth == 2 >>> assert req1.pos == 5 """ def __init__( self, depth: Optional[int] = None, pos: Optional[int] = None, payload: Optional[bytes] = None, ): self._struct = PKTree().get_structure() super().__init__(msg_type=STATIC_KEY_REQUEST, payload=payload) if payload is not None and (depth is not None or pos is not None): raise TypeError if payload: self._unpack() elif depth is not None and pos is not None: if not isinstance(depth, int): raise TypeError if not isinstance(pos, int): raise TypeError if depth not in self._struct.keys() or pos not in range( 0, self._struct[depth] ): raise ValueError self._depth = depth self._pos = pos self._pack() def _pack(self) -> None: """Generates request payload for the static key request""" if self._depth is None or self._pos is None: raise TypeError super()._pack_bts( [ int.to_bytes(self._depth, 1, "little"), int.to_bytes(self._pos, 2, "little"), b"\x00" * len(_mock_tree.get_node(self._depth, self._pos)), ] ) def _unpack(self) -> None: """Parse values from packet payload""" if not self._payload: raise AttributeError u = super()._unpack_bts() if len(u) != 3: raise UnpackException self._depth = int.from_bytes(u[0], "little") self._pos = int.from_bytes(u[1], "little") @property def depth(self) -> int: if self._depth is None: raise AttributeError return self._depth @property def pos(self) -> int: if self._pos is None: raise AttributeError return self._pos class StaticKeyResponse(KeyRequest): """Response object containing a single packet of the static public key Merkle tree Example: >>> from pqconnect.request import StaticKeyResponse, STATIC_KEY_RESPONSE >>> resp0 = StaticKeyResponse(depth=1, pos=0, keydata=bytes.fromhex('00' * 32)) >>> payload = resp0.payload >>> assert payload[:2] == STATIC_KEY_RESPONSE >>> resp1 = StaticKeyResponse(payload=payload) >>> assert resp1.depth == 1 >>> assert resp1.pos == 0 >>> assert resp1.keydata == bytes.fromhex('00' * 32) """ def __init__( self, payload: Optional[bytes] = None, depth: Optional[int] = None, pos: Optional[int] = None, keydata: Optional[bytes] = None, ): self._struct = PKTree().get_structure() super().__init__(STATIC_KEY_RESPONSE, payload) # can either have payload OR (depth & pos & keydata) if payload and (depth or pos or keydata): raise TypeError elif payload: self._unpack() else: # Check types if not isinstance(depth, int): raise TypeError elif not isinstance(pos, int): raise TypeError elif not isinstance(keydata, bytes): raise TypeError # Check values elif depth not in self._struct.keys(): raise ValueError elif pos not in range(0, self._struct[depth]): raise ValueError self._depth: int = depth self._pos: int = pos self._keydata: bytes = keydata self._pack() def _pack(self) -> None: """Creates payload from object values""" super()._pack_bts( [ int.to_bytes(self._depth, 1, "little"), int.to_bytes(self._pos, 2, "little"), self._keydata, ] ) def _unpack(self) -> None: """Populates object values from payload""" u = super()._unpack_bts() if len(u) != 3: raise UnexpectedRequestException self._depth = int.from_bytes(u[0], "little") if self._depth not in self._struct.keys(): raise UnpackException self._pos = int.from_bytes(u[1], "little") if self._pos < 0 or self._pos >= self._struct[self._depth]: raise UnpackException self._keydata = u[2] if len(self._keydata) > SEG_LEN: raise UnpackException @property def depth(self) -> int: """Get depth""" return self._depth @property def pos(self) -> int: """Get packet index""" return self._pos @property def keydata(self) -> bytes: """Get packet data""" return self._keydata class EphemeralKeyRequest(KeyRequest): """Request type for ephemeral keys""" def __init__(self) -> None: super().__init__(EPHEMERAL_KEY_REQUEST) super()._pack_bts( # make request correct length [bytes(ekem.pklen), bytes(dh.lib25519_dh_PUBLICKEYBYTES)] ) class EphemeralKeyResponse(KeyRequest): """Response object containing server's ephemeral public keys""" def __init__( self, payload: Optional[bytes] = None, pqpk: Optional[bytes] = None, npqpk: Optional[bytes] = None, ): super().__init__(EPHEMERAL_KEY_RESPONSE, payload) if payload and (npqpk or pqpk): raise TypeError elif payload: self._unpack() else: if not isinstance(pqpk, bytes): raise TypeError if not isinstance(npqpk, bytes): raise TypeError if len(pqpk) != ekem.pklen: raise ValueError if len(npqpk) != dh.lib25519_dh_PUBLICKEYBYTES: raise ValueError self._pqpk = pqpk self._npqpk = npqpk self._pack() def _pack(self) -> None: super()._pack_bts([self._pqpk, self._npqpk]) def _unpack(self) -> None: u = super()._unpack_bts() if len(u) != 2: raise UnpackException if len(u[0]) != ekem.pklen: raise UnpackException if len(u[1]) != dh.lib25519_dh_PUBLICKEYBYTES: raise UnpackException self._pqpk = u[0] self._npqpk = u[1] @property def pqpk(self) -> bytes: return self._pqpk @property def npqpk(self) -> bytes: return self._npqpk class KeyRequestHandler: """Class to obtain a KeyRequest subclass from a request received on the wire """ def __init__(self, data: bytes): self.payload = data def request(self) -> Optional[KeyRequest]: """Return a KeyRequest subclass from payload""" r: Optional[KeyRequest] = None t = self.payload[: len(STATIC_KEY_REQUEST)] if t == STATIC_KEY_REQUEST: r = StaticKeyRequest(payload=self.payload) elif t == EPHEMERAL_KEY_REQUEST: r = EphemeralKeyRequest() return r class KeyResponseHandler: """Class to obtain a KeyRequest subclass from a request received on the wire """ def __init__(self, data: bytes): self.payload = data def response(self) -> Union[EphemeralKeyResponse, StaticKeyResponse, None]: """Return a KeyRequest subclass from payload""" r: Union[EphemeralKeyResponse, StaticKeyResponse, None] = None t = self.payload[: len(STATIC_KEY_REQUEST)] try: if t == STATIC_KEY_RESPONSE: r = StaticKeyResponse(payload=self.payload) elif t == EPHEMERAL_KEY_RESPONSE: r = EphemeralKeyResponse(payload=self.payload) else: raise Exception except Exception: logger.exception("Invalid response received") return None return r pqconnect-1.2.1/src/pqconnect/request_static_key.py0000644000000000000000000001201414733452565021261 0ustar rootrootimport selectors import sys from socket import AF_INET, SOCK_DGRAM, socket from threading import Event, Thread from time import monotonic, sleep from typing import Tuple from pqconnect.keys import PKTree from pqconnect.pacing.pacing import PacingConnection, PacingPacket from pqconnect.request import StaticKeyRequest, StaticKeyResponse """ Client starts sending requests in breadth-first order. There is a thread sending packets and a thread receiving packets. When a packet is sent, a semaphore must first be acquired When a response is received, if the packet verifies, two requests are removed from """ THRESHOLD = 0.0001 HDRLEN = 28 def _queue_init(tree: PKTree) -> dict: """Generate a pacing packet to track each request packet sent to the keyserver""" pacing_packets = {} tree_struct = tree.get_structure() for depth in tree_struct.keys(): for pos in range(tree_struct[depth]): req = StaticKeyRequest(depth=depth, pos=pos).payload pacing_pkt = PacingPacket(len(req) + HDRLEN) pacing_packets[(depth, pos)] = pacing_pkt return pacing_packets def _send_level_from_queue( level: int, sock: socket, tree: PKTree, pc: PacingConnection, pacing_packets: dict, ) -> None: selector = selectors.DefaultSelector() selector.register(sock, selectors.EVENT_READ) indices = [] for a, b in pacing_packets.keys(): if a == level: indices.append((a, b)) if level == 1: indices.insert(0, (0, 0)) pkts = [pacing_packets[a, b] for a, b in indices] while indices: pc.now_update() p, idx, min_whenrto = min_rto(pc, indices, pacing_packets) whendecongested = float(pc.whendecongested(p.len)) if whendecongested < THRESHOLD: if p.acknowledged: indices.remove(idx) continue if min_whenrto < THRESHOLD: a, b = idx req = StaticKeyRequest(a, b).payload sock.send(req) pc.transmitted(p) else: min_whenrto = whendecongested evts = selector.select(timeout=max(0, min_whenrto)) if evts: _recv(sock, tree, pc, pacing_packets) status(pkts, level) def min_rto( pc: PacingConnection, indices: list, pacing_packets: dict ) -> Tuple[PacingPacket, Tuple[int, int], float]: pc.now_update() pkts = [(i, pacing_packets[i]) for i in indices] i, p = min(pkts, key=lambda p: pc.whenrto(p[1])) return p, i, pc.whenrto(p) # while count: # count = tree_struct[level] # for j in range(tree_struct[level]): # pacing_packet = pacing_packets[level, j] # if pacing_packet.acknowledged: # count -= 1 # continue # else: # req = StaticKeyRequest(level, j).payload # pc.now_update() # when = pc.whenrto(pacing_packet) # if when > THRESHOLD: # continue # # when = float(pc.whendecongested(len(req) + HDRLEN)) # if when > THRESHOLD: # evts = selector.select(timeout=when) # if evts: # _recv(sock, tree, pc, pacing_packets) # if pacing_packet.acknowledged: # continue def status(pkts: list, level: int) -> None: total = len(pkts) acked = len([p for p in pkts if p.acknowledged]) def _send_from_queue( sock: socket, tree: PKTree, pc: PacingConnection, pacing_packets: dict, ) -> None: _send_level_from_queue(1, sock, tree, pc, pacing_packets) _send_level_from_queue(2, sock, tree, pc, pacing_packets) _send_level_from_queue(3, sock, tree, pc, pacing_packets) def _recv( sock: socket, tree: PKTree, pc: PacingConnection, pacing_packets: dict, ) -> None: """Receive as many packets as are currently available and break once the receive buffer is empty Socket must be non-blocking """ while True: try: data, _ = sock.recvfrom(4096) response = StaticKeyResponse(payload=data) depth = response.depth pos = response.pos keydata = response.keydata if not tree.insert_node(depth, pos, keydata): raise ValueError pacing_packet = pacing_packets[(depth, pos)] pc.now_update() pc.acknowledged(pacing_packet) except BlockingIOError: break except ValueError: continue def request_static_keys(tree: PKTree, ip: str, port: int) -> None: """Requests static keys from the given ip and port and stores the received packets in the given PKTree object""" start = monotonic() pc = PacingConnection() s = socket(AF_INET, SOCK_DGRAM) s.connect((ip, port)) s.setblocking(False) pacing_packets = _queue_init(tree) _send_from_queue(s, tree, pc, pacing_packets) s.close() end = monotonic() print(f"Duration: {end - start}") pqconnect-1.2.1/src/pqconnect/server.py0000644000000000000000000001334214733452565016665 0ustar rootrootfrom multiprocessing import Event, Pipe, Process from os import _exit from os.path import basename, dirname, join from socket import inet_aton from sys import exit as bye from time import time import click from pqconnect.common.constants import ( DAY_SECONDS, IP_SERVER, KEYPORT, MCELIECE_PK_PATH, MCELIECE_SK_PATH, PQCPORT, PRIVSEP_USER, SESSION_KEY_PATH, X25519_PK_PATH, X25519_SK_PATH, ) from pqconnect.common.util import display_version, run_as_user from pqconnect.iface import create_tun_interface, tun_listen from pqconnect.keyserver import KeyServer from pqconnect.keystore import EphemeralPrivateKeystore from pqconnect.log import logger from pqconnect.pqcserver import PQCServer @run_as_user(PRIVSEP_USER) def run_server( pqcs: PQCServer, keyserver: KeyServer, testing: bool = False ) -> None: """Runs the main client process as an unprivileged user""" # Create a new KeyServer object ev = Event() pqcs.start() keyserver.start() try: while not ev.is_set(): # Generate ephemeral keys now = int(time()) logger.info("Generating ephemeral keypairs") keystore = EphemeralPrivateKeystore(now) logger.info("Generating public ephemeral keystore") pkstore = keystore.get_public_keystore() # Add ephemeral private keys to the PQConnect server pqcs.set_keystore(keystore) # Add ephemeral public keys to the keyserver keyserver.set_keystore(pkstore) logger.info("Done generating ephemeral keypairs") ev.wait(DAY_SECONDS) except KeyboardInterrupt: logger.info("Shutting down PQConnect Server") ev.set() _exit(0) except Exception as e: logger.exception(e) ev.set() _exit(1) @click.command() @click.option("--version", is_flag=True, help="Display version") @click.option( "-d", "--keydir", type=click.Path( file_okay=False, dir_okay=True, readable=True, resolve_path=True, ), default=dirname(MCELIECE_SK_PATH), help="Directory containing long-term keys", ) @click.option( "-k", "--keyport", type=click.IntRange(0, 65535), default=KEYPORT, help="UDP listening port for key server", ) @click.option( "-p", "--port", type=click.IntRange(0, 65535), default=PQCPORT, help="UDP listening port", ) @click.option( "-a", "--addr", type=click.STRING, default=IP_SERVER, help="local IPv4 address", ) @click.option( "-m", "--mask", type=click.IntRange(8, 24), default=16, help="netmask for private network", ) @click.option( "-i", "--interface-name", type=click.STRING, default="pqcserv0", help="PQConnect network interface name", ) @click.option("-v", "--verbose", is_flag=True, help="enable verbose logging") @click.option( "-vv", "--very-verbose", is_flag=True, help="enable even more verbose logging", ) @click.option( "-H", "--host-ip", type=click.STRING, help="IP address where decrypted traffic should arrive (required if PQConnect is running on a VM, for example)", ) def main( version: bool, keydir: str, port: int, keyport: int, addr: str, mask: int, interface_name: str, verbose: bool, very_verbose: bool, host_ip: str, ) -> None: if version: display_version() bye() # Check addr for validity try: inet_aton(addr) except OSError: raise ValueError(f"Invalid IPv4 address: {addr}") # If host_ip was provided, check it is a valid ip address if host_ip: try: inet_aton(host_ip) except OSError: raise ValueError(f"Invalid IPv4 address: {host_ip}") if verbose: logger.setLevel(10) elif very_verbose: logger.setLevel(9) # Create TUN device try: tun_file = create_tun_interface(interface_name, addr, mask) except Exception: logger.exception("Could not create TUN device") bye(1) # Create pipe for interprocess communication root_conn, user_conn = Pipe() child_sig = Event() # Create subprocesses try: # Create PQCServer mceliece_path = join(keydir, basename(MCELIECE_SK_PATH)) mceliece_pk_path = join(keydir, basename(MCELIECE_PK_PATH)) x25519_path = join(keydir, basename(X25519_SK_PATH)) x25519_pk_path = join(keydir, basename(X25519_PK_PATH)) skey_path = join(keydir, basename(SESSION_KEY_PATH)) pqcs = PQCServer( mceliece_path, x25519_path, skey_path, port, tun_conn=user_conn, dev_name=interface_name, host_ip=host_ip, ) # Create KeyServer keyserver = KeyServer(mceliece_pk_path, x25519_pk_path, keyport) server_process = Process(target=run_server, args=(pqcs, keyserver)) # Create tun_listen process tun_process = Process( target=tun_listen, args=(tun_file, root_conn, child_sig), ) except Exception as e: logger.exception(e) bye(2) # Run try: tun_process.start() server_process.start() tun_process.join() server_process.join() except KeyboardInterrupt: logger.log(9, "KeyboardInterrupt caught in parent process") finally: tun_process.terminate() server_process.terminate() logger.log(9, "Closing Keyserver") keyserver.close() logger.log(9, "Keyserver closed") logger.log(9, "Closing PQCServer") pqcs.close() logger.log(9, "PQCServer closed") bye() if __name__ == "__main__": main() pqconnect-1.2.1/src/pqconnect/tundevice.py0000644000000000000000000005201414733452565017344 0ustar rootrootimport sys from multiprocessing import Queue from multiprocessing.connection import Connection from selectors import EVENT_READ, DefaultSelector from socket import AF_INET, SOCK_DGRAM, inet_aton, inet_ntop, socket from threading import Event, Thread from typing import TYPE_CHECKING, Dict, Optional if TYPE_CHECKING: from .pqcserver import PQCServer import ipaddress from signal import SIGUSR1, signal from time import monotonic from types import FrameType from typing import Optional, Union from pyroute2 import IPRoute from scapy.all import IP, TCP, UDP from pqconnect.common.constants import ( EPOCH_DURATION_SECONDS, HANDSHAKE_FAIL, INITIATION_MSG, MAX_CONNS, PQCPORT, TIDLEN, TUNNEL_MSG, ) from pqconnect.cookie.cookie import Cookie, InvalidCookieMsgException from pqconnect.cookie.cookiemanager import CookieManager, TimestampError from pqconnect.log import logger from pqconnect.peer import Peer, PeerState from pqconnect.tunnel import TunnelSession class TunDevice: """TunDevice encapsulates both a TUN/TAP device in TUN mode and maintains the list of active peer connections. We assign a dedicated IP to the device and perform NAT so that PQConnect peers will be routed to addresses in that subnet, just like in a VPN. This ensures that packets to and from PQConnect peers all appear to be coming from the device's address space and will be routed accordingly. Performing per-peer NAT also allows us to avoid modifying routing tables, which can be messy and easily get changed by the operating system. """ def __init__( self, port: int, tun_conn: Connection, server: Optional["PQCServer"] = None, cookie_manager: Optional[CookieManager] = None, dev_name: str = "pqc0", subnet: str = "", # For testing listening_ip: str = "0.0.0.0", host_ip: Optional[str] = None, ): # Reference to server object to create self._server = server self._cookie_manager = cookie_manager if self._cookie_manager: self._cookie_manager.start() # UDP socket to send and receive encapsulated packets self._tunnel_sock = socket(AF_INET, SOCK_DGRAM) self._tunnel_sock.bind((listening_ip, port)) # Host IP self._host_ip = host_ip # Unix socket to communicate with parent process self._tun_conn = tun_conn # At various times we need to find peers from their attributes self._pkh2peer: Dict[bytes, Peer] = {} self._tid2peer: Dict[bytes, Peer] = {} self._int2peer: Dict[str, Peer] = {} self._ext2peer: Dict[str, Peer] = {} # Maintenance thread self._end_cond = Event() self._pruning_thread = Thread(target=self.remove_expired_peers) self._pruning_thread.start() # get ip and prefixlen from TUN device self._dev_name = dev_name self._my_ip, self._prefix_len = self._get_ip_from_iface(self._dev_name) if subnet: self._subnet4: bytes = inet_aton(subnet) else: subnet_int = int.from_bytes(inet_aton(self._my_ip), "big") & ( 0xFFFFFFFF << (32 - self._prefix_len) ) self._subnet4 = int.to_bytes(subnet_int, 4, "big") # Create a FIFO packet queue for sending/receiving self._send_queue: Queue = Queue() self._recv_queue: Queue = Queue() self._handshake_queue: Queue = Queue() self._session_resume_queue: Queue = Queue() # initialize next_ip self._next_ip = 2 # Register signal handler signal(SIGUSR1, self.print_from_signal) def print_from_signal( self, signum: int, frame: Union[int, Optional[FrameType]] ) -> None: """Prints active connections when SIGUSR1 is sent""" self.print_active_sessions() def print_active_sessions(self) -> None: """Prints the currently active sessions to stdout""" now = monotonic() pretty = ( f"\x1b[33;93mActive Sessions: \x1b[4;91m{self._dev_name}" "\n\x1b[4;92mTunnelID\x1b[0m " "\x1b[4;92mExternal IP\x1b[0m " "\x1b[4;92mInternal IP\x1b[0m " "\x1b[4;92mLast Active\x1b[0m" ) for peer in self._int2peer.values(): pretty += ( "\n\x1b[33;93m{:<12}".format(peer.get_tid().hex()[-8:]) + "{:<15}".format(peer.get_external_ip()) + "{:<15}".format(peer.get_internal_ip()) ) last_ts = peer.last_used() if last_ts: pretty += "{:<.3f}s ago\x1b[0m\n".format(now - last_ts) else: pretty += "NEW\x1b[0m\n" print(pretty) def _get_ip_from_iface(self, iface_name: str) -> tuple[str, int]: """Returns the IP address and prefix length of @iface_name""" ip = IPRoute() dev_idx = ip.link_lookup(ifname=iface_name)[0] dump = ip.addr("dump", index=dev_idx) ip.close() return dump[0].get_attr("IFA_LOCAL"), dump[0]["prefixlen"] def _pton(self, addr: str) -> int: """Get the (32 - prefix_len) least-significant bits of an IPv4 addr, interpreted as a BE int """ return int.from_bytes(inet_aton(addr), "big") & ( 0xFFFFFFFF >> self._prefix_len ) def _make_local_ipv4(self, n: int) -> str: """Returns the local IPv4 address equal to the bitwise OR of the masked subnet and n as a BE uint. """ if n >= (1 << (32 - self._prefix_len) or n < 0): raise ValueError( f"n must be in the interval [0, {1 << (32 - self._prefix_len)})" ) loc = n.to_bytes(4, "big") ip = bytes((a | b for a, b in zip(self._subnet4, loc))) return inet_ntop(AF_INET, ip) def get_next_ip(self) -> str: """Returns the lowest available IP address in the subnet""" if self._next_ip >= MAX_CONNS: logger.debug("Too many connections. Pruning old ones.") self._prune_connection() return self._make_local_ipv4(self._next_ip) @staticmethod def _is_in_subnet(subnet_addr: str, prefix_len: int, addr: str) -> bool: """Returns whether addr is contained within subnet_addr/prefix_len""" ip = ipaddress.ip_address(addr) subnet = ipaddress.ip_network( f"{subnet_addr}/{prefix_len}", strict=False ) return ip in subnet def is_internal_ip_address(self, addr: str) -> bool: """Returns true if address belongs to device subnet""" return self._is_in_subnet(self._my_ip, self._prefix_len, addr) def add_peer(self, peer: Peer) -> bool: """Add this peer to the collection. If successful, update the NAT counter to the next available internal IP and returns True """ if peer.get_internal_ip() in self._int2peer.keys(): logger.debug("Peer already exists. Ignoring.") return False if not ( peer.get_tid() or peer.get_external_ip() or peer.get_internal_ip() ): logger.error("Peer is misconfigured") return False # Associate pkh to peer, if we're a client if not self._server: if not peer.get_pkh(): logger.error("Server peer has no public key hash.") return False self._pkh2peer[peer.get_pkh()] = peer # Associate existing tunnel with peer, if created self._tid2peer[peer.get_tid()] = peer # Associate internal routing ip with peer self._int2peer[peer.get_internal_ip()] = peer self._ext2peer[peer.get_external_ip()] = peer # increment self.next_ip to next available free address while self._make_local_ipv4(self._next_ip) in self._int2peer.keys(): self._next_ip += 1 logger.debug(f"next routing IP: {self.get_next_ip()}") logger.info( "\x1b[33;20mNew Session Established: " "\x1b[33;92m External IP:\x1b[0m " f"{peer.get_external_ip()} " "\x1b[33;92m Internal IP:\x1b[0m " f"{peer.get_internal_ip()}" ) self.print_active_sessions() return True def remove_expired_peers(self, test: bool = False) -> None: """Housekeeping routine to remove inactive peers. Every EPOCH_DURATION_SECONDS, peers are polled for liveness, and ones that are not alive are removed. """ while True: self._end_cond.wait(timeout=EPOCH_DURATION_SECONDS) expired = [] for peer in self._int2peer.values(): if not peer.is_alive(): expired.append(peer) for peer in expired: self.remove_peer(peer) if self._end_cond.is_set(): break def remove_peer(self, peer: Peer) -> None: """Removes `peer` from the instance. If the internal IP of `peer` is lower than `self.next_ip`, `self.next_ip` is reset to the peer's (masked) internal IP, interpreted as an unsigned int. """ logger.info(f"Removing peer {peer.get_external_ip()}") if peer.get_tid() in self._tid2peer: self._tid2peer.pop(peer.get_tid()) if peer.get_pkh() in self._pkh2peer: self._pkh2peer.pop(peer.get_pkh()) if peer.get_internal_ip() in self._int2peer: self._int2peer.pop(peer.get_internal_ip()) # set self.next_ip to be lowest available free ip if self._pton(peer.get_internal_ip()) < self._next_ip: self._next_ip = self._pton(peer.get_internal_ip()) if peer.get_external_ip() in self._ext2peer: self._ext2peer.pop(peer.get_external_ip()) peer.close() def get_peer_by_pkh(self, pkh: bytes) -> Peer: """Returns the peer indexed by `pkh`. Calling function should catch the potential KeyError """ if not pkh or not isinstance(pkh, bytes): raise TypeError try: return self._pkh2peer[pkh] except KeyError as e: raise ValueError from e def get_pqcport(self) -> int: """Returns the port number of the network-facing socket""" return self._tunnel_sock.getsockname()[1] def _update_incoming_pk_addrs( self, decrypted_packet: bytes, src: str ) -> bytes: """Replaces the source IP on the inner packet with our peer's local routing IP. """ pkt = IP(decrypted_packet) pkt[IP].src = src if self._host_ip: pkt[IP].dst = self._host_ip else: pkt[IP].dst = self._my_ip # We need to delete the existing packet header checksums or scapy will # not recompute it del pkt[IP].chksum if UDP in pkt: del pkt[UDP].chksum elif TCP in pkt: del pkt[TCP].chksum return pkt.build() def _generate_cookie(self, peer: Peer) -> Cookie: """Returns an cookie blob from the peer's current TunnelSession""" # TODO this is not very OOP-like, OOPs if not peer._tunnel or not self._cookie_manager: raise Exception key, nonce = self._cookie_manager.get_cookie_key() cookie = peer._tunnel.to_cookie(key, nonce) # TODO violates OOP return cookie def _send_cookie(self, peer: Peer) -> None: """wraps generate_cookie and sends it over the network""" # Send a cookie to the indicated peer. Shouldn't be called directly, # but as part of a pruning action. try: cookie = self._generate_cookie(peer) except Exception as e: logger.exception(f"Could not generate a cookie for peer") return dst_ip = peer.get_external_ip() dst_port = peer.get_pqcport() self._tunnel_sock.sendto( peer.encrypt(cookie.bytes()), (dst_ip, dst_port) ) def _prune_connection(self) -> None: """Send cookie to older connection if we're a server. Remove the connection. """ old_peer = min(self._tid2peer.values(), key=lambda p: p.last_used()) logger.debug(f"Peer: {old_peer.get_internal_ip()}") if self._cookie_manager: self._send_cookie(old_peer) self.remove_peer(old_peer) def _queue_incoming(self) -> None: """Sort incoming packet into the appropriate queue""" pkt, addr = self._tunnel_sock.recvfrom(4096) # First check if the server received an handshake or cookie message so # that we can create a new connection before handling the rest of the # packet (in the case of a cookie prepended to a regular message) if self._server: # Handle an initiation message if pkt[:2] == INITIATION_MSG: # 1) Complete handshake with packet in unprivileged thread # 2a) If tunnel is successfully created, add peer to tundevice # 2b) else send fail message self._handshake_queue.put((pkt, addr)) logger.log(9, f"handshake message received from {addr[0]}") return # Handle cookie message elif self._cookie_manager and self._cookie_manager.is_cookie(pkt): self._session_resume_queue.put((pkt, addr)) logger.log(9, f"cookie message received from {addr[0]}") return # Handle a message from an established connection. if pkt[:2] == TUNNEL_MSG: self._recv_queue.put((pkt, addr)) return # If we receive a handshake fail message we should remove the peer # immediately elif pkt == HANDSHAKE_FAIL: if addr[0] in self._ext2peer.keys(): peer = self._ext2peer[addr[0]] if peer.get_state() in (PeerState.ESTABLISHED, PeerState.NEW): logger.error("Handshake failed") peer.error() self.remove_peer(peer) def _process_handshake_from_queue(self) -> None: """Gets a handshake message from the queue and processes it""" pkt, addr = self._handshake_queue.get() if self._server: self._server.complete_handshake(pkt, addr) def _process_cookie_from_queue(self) -> bool: """Gets a session restore message from queue and processes it. Returns success as a boolean """ pkt, addr = self._session_resume_queue.get() if not self._cookie_manager: logger.error( 9, "Cookie message received, but we can't issue cookies. ??", ) return False try: tun: TunnelSession = self._cookie_manager.check_cookie(pkt) except InvalidCookieMsgException: logger.exception("Invalid cookie message") return False except TimestampError: logger.exception("Invalid cookie timestamp") return False except Exception: logger.exception("Could not processes cookie message") return False internal_ip = self.get_next_ip() peer = Peer(addr[0], internal_ip) peer.set_tunnel(tun) if addr[1] != PQCPORT: peer.set_pqcport(addr[1]) self.add_peer(peer) return True def _receive_from_queue(self) -> None: """Gets a packet from the receive queue and decrypts it""" pkt, addr = self._recv_queue.get() tid = pkt[2 : 2 + TIDLEN] if tid in self._tid2peer: peer = self._tid2peer[tid] pkt = peer.decrypt(pkt) if pkt: logger.log(9, f"Message received from tunnel {tid.hex()}.") # Check if peer's external IP has changed ext_ip = addr[0] if ext_ip != peer.get_external_ip(): peer.set_external_ip(ext_ip) peer.set_pqcport(addr[1]) # perform NAT so source IP comes from device subnet pkt = self._update_incoming_pk_addrs( pkt, peer.get_internal_ip() ) # Pass decrypted packet to tun device self._tun_conn.send_bytes(pkt) def _queue_send_packet(self) -> None: """Reads packet from the tunnel pipe and adds it to the send queue""" pkt = self._tun_conn.recv_bytes() self._send_queue.put(pkt) def _send_from_queue(self) -> None: """Gets a packet from the send queue and sends it if there is an active connection with the peer. Otherwise it re-inserts the packet into the queue or drops it, as appropriate. If the peer has no established tunnel yet, it reinserts for later processing. If the peer is expired, the packet is dropped. """ pkt = self._send_queue.get() p = IP(pkt) if p[IP].dst in self._int2peer: peer = self._int2peer[p[IP].dst] state = peer.get_state() # If state is new, we're probably still waiting for the connection # to handshake to finish. Place back in the queue and return if state == PeerState.NEW: self._send_queue.put(pkt) return # If the peer has expired/closed/error, drop the packet, remove the # peer, and return elif ( state in [PeerState.EXPIRED, PeerState.CLOSED, PeerState.ERROR] or not peer.is_alive() ): self.remove_peer(peer) return # Getting here means peer exists and connection is good. # Replace inner dst field, since it gets rewritten anyway p.dst = "0.0.0.0" # get outer packet address dst_ip = peer.get_external_ip() dst_port = peer.get_pqcport() self._tunnel_sock.sendto( peer.encrypt(bytes(p)), (dst_ip, dst_port) ) logger.log(9, f"Message sent over tunnel {peer.get_tid().hex()}") def start(self) -> None: """Registers TUN device, external UDP socket, and message queues to the Selector for handling, then polls the selector until we exit. Selecting on private attributes (i.e. "_reader") is fragile, but there does not seem to be a better way to multiplex Queue reading without resorting to even uglier solutions. Also this has been a known issue for more than a decade now https://bugs.python.org/issue3831 """ sel = DefaultSelector() try: # __getattribute__ send_reader = self._send_queue.__getattribute__("_reader") recv_reader = self._recv_queue.__getattribute__("_reader") handshake_reader = self._handshake_queue.__getattribute__( "_reader" ) session_resume_reader = ( self._session_resume_queue.__getattribute__("_reader") ) # Register queues and IO socket_key = sel.register(self._tunnel_sock, EVENT_READ) tun_key = sel.register(self._tun_conn, EVENT_READ) send_key = sel.register(send_reader, EVENT_READ) recv_key = sel.register(recv_reader, EVENT_READ) handshake_key = sel.register(handshake_reader, EVENT_READ) session_resume_key = sel.register( session_resume_reader, EVENT_READ ) except AttributeError: logger.exception("Message queues have no reader attribute") sys.exit(2) except Exception: logger.exception("Cannot register queues with selector") sys.exit(2) while not self._end_cond.is_set(): for key, _ in sel.select(timeout=0.1): if key == socket_key: self._queue_incoming() elif key == tun_key: self._queue_send_packet() # We're the only thread the accessing the queues, so no ToCToU # issues should happen here elif key == send_key: self._send_from_queue() elif key == recv_key: self._receive_from_queue() elif key == handshake_key: self._process_handshake_from_queue() elif key == session_resume_key: self._process_cookie_from_queue() def close(self) -> None: """Terminates the pruning thread, closes all active connections and closes the connection to the TUN listener """ for peer in self._int2peer.values(): peer.close() logger.log(9, "Joining device pruning thread") self._end_cond.set() self._pruning_thread.join() logger.log(9, "device pruning thread joined") self._tunnel_sock.close() self._session_resume_queue.close() self._recv_queue.close() self._send_queue.close() self._handshake_queue.close() if self._cookie_manager: self._cookie_manager.stop() pqconnect-1.2.1/src/pqconnect/tunnel.py0000644000000000000000000004267614733452565016700 0ustar rootrootfrom __future__ import annotations from struct import pack, unpack_from from threading import Lock, Timer from time import monotonic from typing import Dict, Optional from SecureString import clearmem from .common.constants import ( CHAIN_KEY_NUM_PACKETS, COOKIE_PREFIX, EPOCH_DURATION_SECONDS, EPOCH_TIMEOUT_SECONDS, HDRLEN, MAX_CHAIN_LEN, MAX_EPOCHS, TIDLEN, TUNNEL_MSG, ) from .common.crypto import NLEN, TAGLEN, secret_box, secret_unbox from .common.crypto import stream_kdf as kdf from .cookie.cookie import Cookie from .log import logger class ExpiredRatchetException(Exception): """Raised when an operation is called on an expired ratchet""" class EpochRatchetException(Exception): """Raised when an event occurs that attempts to ratchet forward more than MAX_EPOCHS """ class PacketKey: """Object containing a key and its index data""" def __init__(self, epoch: int, ctr: int, key: bytes): self._epoch = epoch self._ctr = ctr self._key = key def get_epoch(self) -> int: return self._epoch def get_ctr(self) -> int: return self._ctr def get_key(self) -> bytes: return self._key class EpochChain: """Single horizontal branch of the christmas tree. root_key: the root key of the epoch. This gets erased during construction epoch: the epoch number start: the timestamp when the epoch is created (default to now) _expire : Time after which a new epoch should be used _ctr : The index of the next packet key to be generated _packet_keys : The encryption/decryption keys for session packets _next_epoch_key : The root key of the subsequent EpochChain _next_chain_key : The next key used by the KDF to derive packet_keys. """ def __init__( self, root_key: bytes, epoch: int, start: Optional[int] = None ): if start is None: start = int(monotonic()) self._expire = start + EPOCH_DURATION_SECONDS self._epoch = epoch self._ctr = 0 self._packet_keys: Dict[int, bytes] = {} next_epoch_key, next_chain_key = kdf(2, root_key) clearmem(root_key) self._next_epoch_key: bytes = next_epoch_key self._next_chain_key: bytes = next_chain_key # Create packet keys self.chain_ratchet() def _get_epoch_no(self) -> int: """Return the epoch number""" return self._epoch def get_expiration_time(self) -> int: return self._expire def chain_ratchet(self) -> None: """Using current chain key, generate stream consisting of a new chain key and CHAIN_KEY_NUM_PACKETS packet keys, immediately overwriting the current chain key for PFS. self.send_chain['ctr'] is always the associative array key of the next packet key to be generated. """ # generate the first CHAIN_KEY_NUM_PACKETS packet keys from this # chain key and immediately overwrite the chain key with the # next key for PFS chain_key, *packet_keys = kdf( CHAIN_KEY_NUM_PACKETS + 1, self._next_chain_key ) clearmem(self._next_chain_key) self._next_chain_key = chain_key # Add new keys to the key dictionary self._packet_keys |= dict( zip( range(self._ctr, self._ctr + CHAIN_KEY_NUM_PACKETS), packet_keys, ) ) # Update the ctr self._ctr += CHAIN_KEY_NUM_PACKETS def expired(self, now: Optional[int] = None) -> bool: """Returns whether the epoch has expired""" if not now: now = int(monotonic()) return now > self._expire def get_next_epoch_key(self) -> bytes: """Returns the root key for the next epoch in the ratchet""" return self._next_epoch_key def get_packet_key(self, index: int) -> PacketKey: """Return the key at index `index` if it exists""" # Create more packet keys if existing ones have all been used if not len(self._packet_keys): self.chain_ratchet() try: # If the index is greater than the maximum paket index, ratchet # forward until bound while ( index > max(self._packet_keys.keys()) and len(self._packet_keys) < MAX_CHAIN_LEN ): self.chain_ratchet() key = self._packet_keys[index] except KeyError as e: raise ValueError from e return PacketKey(self._epoch, index, key) def get_next_chain_key(self) -> PacketKey: """Returns the next unused packet key.""" if not len(self._packet_keys): self.chain_ratchet() min_ctr = min(self._packet_keys.keys()) try: key = self.get_packet_key(min_ctr) except ValueError: logger.exception(f"No key with index {min_ctr} exists in ratchet") return key def delete_packet_key(self, packet_key: PacketKey) -> None: """Zeroes the key and removes it from the ratchet""" if packet_key.get_epoch() != self._epoch: # Fail closed: Can't remove it from the chain but can erase the # key object directly clearmem(packet_key.get_key()) raise ValueError try: ctr = packet_key.get_ctr() key = self._packet_keys.pop(ctr) clearmem(key) except KeyError as e: raise ValueError from e finally: # This should be redundant if no exception was raised. clearmem(packet_key.get_key()) def clear(self) -> None: """Erases all keys in the epoch""" clearmem(self._next_epoch_key) clearmem(self._next_chain_key) while len(self._packet_keys): _, key = self._packet_keys.popitem() clearmem(key) class SendChain: """The key chain for sending packets""" def __init__(self, root_key: bytes, epoch: int = 0): self._epoch = epoch self._chain = EpochChain(root_key, self._epoch) def epoch_ratchet(self, now: Optional[int] = None) -> None: """Increments the epoch counter and sets the chain to a new EpochChain object rooted at the next epoch key. The old chain is then securely erased. """ if not now: now = int(monotonic()) self._epoch += 1 chain = self._chain start_time = min(now, chain.get_expiration_time()) key = chain.get_next_epoch_key() self._chain = EpochChain(key, self._epoch, start=start_time) chain.clear() def get_next_key(self) -> PacketKey: """Get the next packet key in the chain. If the epoch has expired, the chain ratchets forward to a new epoch and recurses. """ if self._chain.expired(): self.epoch_ratchet() return self.get_next_key() else: return self._chain.get_next_chain_key() def get_epoch_no(self) -> int: """Return the current epoch number""" return self._epoch def delete_packet_key(self, packet_key: PacketKey) -> None: """Securely erase the packet key and remove it from the chain.""" try: self._chain.delete_packet_key(packet_key) except Exception: logger.exception("Could not delete") def clear(self) -> None: """Zero out all keys in the chain""" self._chain.clear() class ReceiveChain(SendChain): """The key chain for receiving packets.""" def __init__(self, root_key: bytes, epoch: int = 0): """Unlike the SendChain we maintain a dictionary of EpochChain objects, each of which has a timer thread that deletes it """ self._epoch = epoch chain = EpochChain(root_key, self._epoch) self._chains: Dict[int, EpochChain] = {self._epoch: chain} self._deletion_timers: list = [] self._mut = Lock() t = Timer( EPOCH_TIMEOUT_SECONDS, self.delete_expired_epoch, args=(self._epoch,), ) self._deletion_timers.append(t) t.start() def get_chain_len(self) -> int: """Returns the number of non-expired epochs in the receive chain""" return len(self._chains) def epoch_ratchet(self, now: Optional[int] = None) -> None: """Create a new epoch object and add it to the receive chain. If the ratchet has expired it raises an error. """ # Need to protect receive chain with mutex to avoid # concurrent access by main thread and deletion thread if not now: now = int(monotonic()) with self._mut: if len(self._chains) == 0: raise ExpiredRatchetException chain = self._chains[self._epoch] next_epoch_key = chain.get_next_epoch_key() # Set the next start time to be the minimum of now (we're moving # the clock ahead), or the last epoch's expiration time. We allow # the epoch to expire earlier than planned but not later. start_time = min(now, chain.get_expiration_time()) self._epoch += 1 # Create a new chain, overwriting next_epoch_key in the process new_chain = EpochChain( next_epoch_key, self._epoch, start=start_time ) self._chains[self._epoch] = new_chain # Create deletion timer for the next chain t = Timer( interval=(EPOCH_TIMEOUT_SECONDS), function=self.delete_expired_epoch, args=(self._epoch,), ) self._deletion_timers.append(t) t.start() def get_packet_key(self, epoch: int, ctr: int) -> PacketKey: """Get the packet key from epoch `epoch` at index `ctr` in the ratchet. """ # Ratchet forward to new epoch if needed while ( epoch > max(self._chains.keys()) and len(self._chains) < MAX_EPOCHS ): self.epoch_ratchet() if epoch not in self._chains.keys(): raise EpochRatchetException try: with self._mut: chain = self._chains[epoch] key = chain.get_packet_key(ctr) return key except KeyError as e: raise ValueError from e finally: if self._mut.locked(): self._mut.release() def delete_expired_epoch(self, epoch_no: int) -> None: """Zero memory for and delete all keys in epoch `epoch_no`""" with self._mut: try: epoch = self._chains[epoch_no] epoch.clear() del self._chains[epoch_no] except KeyError: logger.exception(f"Epoch {epoch_no} does not exist") def delete_packet_key(self, packet_key: PacketKey) -> None: """Securely erase the packet key and remove it from the chain""" try: epoch = packet_key.get_epoch() self._chains[epoch].delete_packet_key(packet_key) except Exception: logger.exception("Could not delete key") def clear(self) -> None: """Zero and delete all remaining receive chains""" for timer in self._deletion_timers: timer.cancel() for key in self._chains.keys(): self._chains[key].clear() # Delete the chains from the dictionary self._chains.clear() class TunnelSession: """TunnelSession holds the cryptographic state of a connection with a remote peer. It performs encryption/decryption using symmetric keys and ensures fast key erasure """ def __init__( self, tid: bytes, send_chain_key: bytes, recv_chain_key: bytes ): self._tid = tid self._mut = Lock() self._last_used = 0 # Initialize send key chain self._send_chain = SendChain(send_chain_key) # Initialize receive key chain self._recv_chain = ReceiveChain(recv_chain_key) @classmethod def from_cookie_data( cls, tid: bytes, epoch: int, next_send_epoch_key: bytes, next_recv_epoch_key: bytes, ) -> TunnelSession: """Returns a new TunnelSession object from previous state""" # Create a dummy object. We need to create the send and receive chains # manually session = cls(tid, b"\x00" * 32, b"\x00" * 32) # delete the existing chains session._send_chain.clear() session._recv_chain.clear() # Instantiate send and receive chains from the root keys. The epoch # value function parameter was the epoch when these root keys were the # *next* epoch keys, so we increment the epoch value by 1 and it # becomes the current epoch. session._send_chain = SendChain(next_send_epoch_key, epoch=epoch + 1) session._recv_chain = ReceiveChain( next_recv_epoch_key, epoch=epoch + 1 ) return session def get_epoch(self) -> int: """Returns the current sending epoch of the session""" return self._send_chain.get_epoch_no() def send_epoch_ratchet(self) -> None: """Ratchet the send chain forward one epoch""" self._send_chain.epoch_ratchet() def recv_epoch_ratchet(self) -> None: """Ratchet the receive chain forward one epoch""" with self._mut: self._recv_chain.epoch_ratchet() def close(self) -> None: """Clear both send and receive chains""" with self._mut: self._send_chain.clear() self._recv_chain.clear() def get_most_recent_timestamp(self) -> int: """Returns the timestamp of the last successfully encryption/decryption operation """ return self._last_used def is_alive(self) -> bool: """Returns True if there are unexpired keys in the receiving chain""" return self._recv_chain.get_chain_len() > 0 def get_tid(self) -> bytes: """Returns the TID for the current session""" return self._tid def get_send_key(self) -> PacketKey: """Returns the next packet key in the send chain.""" with self._mut: return self._send_chain.get_next_key() def get_recv_key(self, epoch: int, ctr: int) -> PacketKey: """Return the packet key with index (epoch, ctr) if it exists. Raises a ValueError if it does not exist. """ try: key = self._recv_chain.get_packet_key(epoch, ctr) except (ValueError, EpochRatchetException): logger.exception( "Invalid receive key index requested. " f"Current epoch: {self.get_epoch()}. Request: {epoch}, {ctr}" ) return key def tunnel_send(self, packet: bytes, now: Optional[int] = None) -> bytes: """Encrypt packet under the next packet key in the send chain""" if not now: now = int(monotonic()) try: packet_key = self.get_send_key() except Exception: logger.exception(f"Could not get packet key") return b"" epoch = packet_key.get_epoch() ctr = packet_key.get_ctr() key = packet_key.get_key() hdr = TUNNEL_MSG + self._tid + pack("!HI", epoch, ctr) enc, tag = secret_box(key, b"\x00" * NLEN, packet, hdr) # Update _last_used self._last_used = now # erase and delete the key from the send chain self._send_chain.delete_packet_key(packet_key) return b"".join([hdr, enc, tag]) def tunnel_recv( self, encrypted_packet: bytes, now: Optional[int] = None ) -> bytes: """Decrypt the incoming packet and return the plaintext. If decryption fails, the method returns an empty string """ if now: self._last_used = now else: self._last_used = int(monotonic()) try: epoch, ctr = unpack_from( "!HI", encrypted_packet[len(TUNNEL_MSG) + TIDLEN :] ) packet_key = self.get_recv_key(epoch, ctr) key = packet_key.get_key() msg = secret_unbox( key, b"\x00" * NLEN, encrypted_packet[-TAGLEN:], encrypted_packet[HDRLEN:-TAGLEN], encrypted_packet[:HDRLEN], ) self._recv_chain.delete_packet_key(packet_key) if msg[: len(COOKIE_PREFIX)] == COOKIE_PREFIX: self.recv_epoch_ratchet() except Exception as e: logger.log(9, f"Tunnel decryption failed: {e}") return b"" finally: while ( self._recv_chain.get_epoch_no() > self._send_chain.get_epoch_no() ): self.send_epoch_ratchet() return msg def to_cookie( self, key: bytes, nonce: bytes, timestamp: Optional[int] = None ) -> Cookie: """Returns a cookie from the current session state and closes the session """ if not timestamp: timestamp = int(monotonic()) with self._mut: epoch = self._send_chain._epoch send_root = self._send_chain._chain._next_epoch_key recv_root = self._recv_chain._chains[epoch]._next_epoch_key cookie = Cookie.from_session_values( key, nonce, timestamp, self._tid, epoch, send_root, recv_root ) self.close() return cookie pqconnect-1.2.1/src/pqconnect/util.py0000644000000000000000000000453214733452565016335 0ustar rootrootfrom sys import exit import click from dns import resolver from dns.rdtypes.ANY.TXT import TXT from dns.resolver import NXDOMAIN, NoNameservers from .common.constants import DNS_EXAMPLE_HOST def dns_query_server(hostname: str) -> tuple: """ Query CNAME and server port for hostname """ query_name: str = DNS_EXAMPLE_HOST try: answer_a: resolver.Answer = resolver.resolve(hostname, "A") if not answer_a.rrset: raise Exception("No response record") if not answer_a.canonical_name: raise Exception("No CNAME") cn: str = answer_a.canonical_name.to_text() for a in answer_a.rrset.to_rdataset(): ip: str = a pk: str = cn.split("pq1")[1].split(".")[0] except Exception as e: print(f"Server DNS A record is misconfigured: {e}") exit(1) try: answer: resolver.Answer = resolver.resolve(cn, "TXT") if not answer.rrset: raise Exception("No response record") response_data: str = answer.rrset.pop().to_text() port = response_data.split('"')[1].split("=")[1] except Exception as e: print(f"Server DNS TXT record is misconfigured: {e}") exit(2) return (cn, pk, ip, port) def dns_query_keyserver(cname: str) -> tuple: query_name: str = "ks." + cname try: answer: resolver.Answer = resolver.resolve(query_name, "TXT") if not answer.rrset: raise Exception("No response record") response_data: TXT = answer.rrset.pop() ip, port = [ r.split("=")[1].strip() for r in response_data.to_text().replace('"', "").split(";") ] except Exception as e: print(f"Keyserver DNS TXT record is misconfigured: {e}") exit(3) return (ip, port) @click.command() @click.option( "-h", "--hostname", default=DNS_EXAMPLE_HOST, type=click.STRING, required=False, ) def dns_query_main(hostname: str) -> tuple: """ Query pqconnect related DNS records for a given hostname """ cn, pk, ip, port = dns_query_server(hostname) ks_ip, ks_port = dns_query_keyserver(cn) print( f"Resolving: {hostname}\n" f"Found server: cname {cn}; ip {ip}; port {port}; with pk {pk}\n" f"Found Keyserver: ip {ks_ip}; port {ks_port}" ) return (hostname, ip, port, pk) pqconnect-1.2.1/test/0000755000000000000000000000000014733452565013200 5ustar rootrootpqconnect-1.2.1/test/__init__.py0000644000000000000000000000000014733452565015277 0ustar rootrootpqconnect-1.2.1/test/cookie/0000755000000000000000000000000014733452565014451 5ustar rootrootpqconnect-1.2.1/test/cookie/test_cookie.py0000644000000000000000000000524114733452565017335 0ustar rootrootfrom struct import pack from unittest import TestCase, main from pqconnect.common.constants import COOKIE_PREFIX_LEN, TIDLEN from pqconnect.common.crypto import KLEN, NLEN, secret_unbox from pqconnect.cookie.cookie import Cookie, InvalidCookieMsgException class TestCookie(TestCase): def setUp(self) -> None: self.cookie_key = b"\x00" * KLEN self.nonce = b"\x00" * NLEN self.timestamp = 0 self.tid = b"\x00" * TIDLEN self.epoch = 0 self.send_root = b"\x01" * KLEN self.recv_root = b"\x02" * KLEN def tearDown(self) -> None: pass def _check_cookie(self, cookie: Cookie) -> None: self.assertEqual(cookie.timestamp(), self.timestamp) self.assertEqual(cookie.nonce(), self.nonce) ct, tag = cookie.ct() ts_bts = pack("!Q", self.timestamp) try: secret_unbox(self.cookie_key, self.nonce, tag, ct, ts_bts) except Exception as e: self.assertTrue(False, f"could not decrypt ciphertext: {e}") def test_from_session_values(self) -> None: """Checks that Cookie object is successfully created from session values """ cookie = Cookie.from_session_values( self.cookie_key, self.nonce, self.timestamp, self.tid, self.epoch, self.send_root, self.recv_root, ) self._check_cookie(cookie) def test_from_bytes(self) -> None: """Tests that cookie can be successfully deserialized from bytes""" cookie = Cookie.from_session_values( self.cookie_key, self.nonce, self.timestamp, self.tid, self.epoch, self.send_root, self.recv_root, ) goodbytes = cookie.bytes() try: new_cookie = Cookie.from_bytes(goodbytes) self._check_cookie(new_cookie) except Exception as e: self.assertTrue(False, e) badbytes = goodbytes[:-1] try: new_cookie = Cookie.from_bytes(badbytes) self.assertTrue(False) except InvalidCookieMsgException: self.assertTrue(True) badbytes = b"\x00" * COOKIE_PREFIX_LEN + goodbytes[COOKIE_PREFIX_LEN:] try: new_cookie = Cookie.from_bytes(badbytes) self.assertTrue(False) except InvalidCookieMsgException: self.assertTrue(True) notbytes = 42 try: new_cookie = Cookie.from_bytes(notbytes) self.assertTrue(False) except TypeError: self.assertTrue(True) if __name__ == "__main__": main() pqconnect-1.2.1/test/cookie/test_cookie_manager.py0000644000000000000000000000623414733452565021032 0ustar rootrootimport os import unittest from time import monotonic, sleep from pqconnect.common.crypto import KLEN from pqconnect.common.util import round_timestamp from pqconnect.cookie.cookie import Cookie from pqconnect.cookie.cookiemanager import CookieManager from pqconnect.tunnel import TunnelSession class TestCookieManager(unittest.TestCase): def setUp(self) -> None: self.session_key = b"\x00" * KLEN self.seed = b"\x00" * KLEN self.cm = CookieManager(self.session_key, self.seed) sleep(0.01) def tearDown(self) -> None: pass def test__update_deterministic(self) -> None: timestamp = 0 orig_len = len(self.cm._keystore) self.cm._update_deterministic(timestamp, b"\x00" * KLEN) self.assertEqual(len(self.cm._keystore), orig_len + 1) key, nonce = self.cm.get_cookie_key(timestamp) self.assertEqual( key, ( b"A\xa5}\x96\xbe\x82 None: pass def test_increment_nonce(self) -> None: now = round_timestamp(monotonic()) self.cm._update() self.assertIsNotNone(self.cm._keystore[now]) old_ctr = self.cm._keystore[now]["ctr"] self.cm._increment_nonce(now) self.assertEqual(old_ctr + 1, self.cm._keystore[now]["ctr"]) def test_get_cookie_key(self) -> None: now = round_timestamp(monotonic()) self.cm._update() key, _ = self.cm.get_cookie_key(now) self.assertIsInstance(key, bytes) self.assertEqual(len(key), KLEN) def test_cookie_correctness(self) -> None: tid = os.urandom(32) t1_send_root = os.urandom(32) t1_recv_root = os.urandom(32) t2_send_root = bytes( [t1_recv_root[i] for i in range(len(t1_recv_root))] ) t2_recv_root = bytes( [t1_send_root[i] for i in range(len(t1_send_root))] ) # local state tun1 = TunnelSession(tid, t1_send_root, t1_recv_root) # remote state tun2 = TunnelSession(tid, t2_send_root, t2_recv_root) self.assertEqual( tun1.tunnel_recv(tun2.tunnel_send(b"hello")), b"hello" ) # generate cookie and delete local state self.cm.start() sleep(0.01) key, nonce = self.cm.get_cookie_key() print("baking cookies") cookie: Cookie = tun1.to_cookie(key, nonce) print("cookies are ready") self.assertIsNotNone(cookie) tun1.close() del tun1 # send cookie remotely, peer updates local state tun2.send_epoch_ratchet() tun2.recv_epoch_ratchet() # we get the cookie back, recreate state new_tun = self.cm.check_cookie(cookie.bytes()) self.assertIsNotNone(new_tun) for _ in range(10): self.assertEqual( new_tun.tunnel_recv(tun2.tunnel_send(b"hello")), b"hello" ) # close tunnels to kill running threads tun2.close() new_tun.close() self.cm.stop() pqconnect-1.2.1/test/test_aead.py0000644000000000000000000002707014733452565015511 0ustar rootrootimport unittest from pqconnect.common.crypto import ( KLEN, NLEN, TAGLEN, secret_box, secret_unbox, ) class TestAEAD(unittest.TestCase): def test_aead(self) -> None: r"""Test vector from RFC 8439 Appendix A.5: Below we see decrypting a message. We receive a ciphertext, a nonce, and a tag. We know the key. We will check the tag and then (assuming that it validates) decrypt the ciphertext. In this particular protocol, we'll assume that there is no padding of the plaintext. The ChaCha20 Key 000 1c 92 40 a5 eb 55 d3 8a f3 33 88 86 04 f6 b5 f0 ..@..U...3...... 016 47 39 17 c1 40 2b 80 09 9d ca 5c bc 20 70 75 c0 G9..@+....\. pu. Ciphertext: 000 64 a0 86 15 75 86 1a f4 60 f0 62 c7 9b e6 43 bd d...u...`.b...C. 016 5e 80 5c fd 34 5c f3 89 f1 08 67 0a c7 6c 8c b2 ^.\.4\....g..l.. 032 4c 6c fc 18 75 5d 43 ee a0 9e e9 4e 38 2d 26 b0 Ll..u]C....N8-&. 048 bd b7 b7 3c 32 1b 01 00 d4 f0 3b 7f 35 58 94 cf ...<2.....;.5X.. 064 33 2f 83 0e 71 0b 97 ce 98 c8 a8 4a bd 0b 94 81 3/..q......J.... 080 14 ad 17 6e 00 8d 33 bd 60 f9 82 b1 ff 37 c8 55 ...n..3.`....7.U 096 97 97 a0 6e f4 f0 ef 61 c1 86 32 4e 2b 35 06 38 ...n...a..2N+5.8 112 36 06 90 7b 6a 7c 02 b0 f9 f6 15 7b 53 c8 67 e4 6..{j|.....{S.g. 128 b9 16 6c 76 7b 80 4d 46 a5 9b 52 16 cd e7 a4 e9 ..lv{.MF..R..... 144 90 40 c5 a4 04 33 22 5e e2 82 a1 b0 a0 6c 52 3e .@...3"^.....lR> 160 af 45 34 d7 f8 3f a1 15 5b 00 47 71 8c bc 54 6a .E4..?..[.Gq..Tj 176 0d 07 2b 04 b3 56 4e ea 1b 42 22 73 f5 48 27 1a ..+..VN..B"s.H'. 192 0b b2 31 60 53 fa 76 99 19 55 eb d6 31 59 43 4e ..1`S.v..U..1YCN 208 ce bb 4e 46 6d ae 5a 10 73 a6 72 76 27 09 7a 10 ..NFm.Z.s.rv'.z. 224 49 e6 17 d9 1d 36 10 94 fa 68 f0 ff 77 98 71 30 I....6...h..w.q0 240 30 5b ea ba 2e da 04 df 99 7b 71 4d 6c 6f 2c 29 0[.......{qMlo,) 256 a6 ad 5c b4 02 2b 02 70 9b ..\..+.p. The nonce: 000 00 00 00 00 01 02 03 04 05 06 07 08 ............ The AAD: 000 f3 33 88 86 00 00 00 00 00 00 4e 91 .3........N. Received Tag: 000 ee ad 9d 67 89 0c bb 22 39 23 36 fe a1 85 1f 38 ...g..."9#6....8 First, we calculate the one-time Poly1305 key ChaCha state with key setup 61707865 3320646e 79622d32 6b206574 a540921c 8ad355eb 868833f3 f0b5f604 c1173947 09802b40 bc5cca9d c0757020 00000000 00000000 04030201 08070605 ChaCha state after 20 rounds a94af0bd 89dee45c b64bb195 afec8fa1 508f4726 63f554c0 1ea2c0db aa721526 11b1e514 a0bacc0f 828a6015 d7825481 e8a4a850 d9dcbbd6 4c2de33a f8ccd912 out bytes: bd:f0:4a:a9:5c:e4:de:89:95:b1:4b:b6:a1:8f:ec:af: 26:47:8f:50:c0:54:f5:63:db:c0:a2:1e:26:15:72:aa Poly1305 one-time key: 000 bd f0 4a a9 5c e4 de 89 95 b1 4b b6 a1 8f ec af ..J.\.....K..... 016 26 47 8f 50 c0 54 f5 63 db c0 a2 1e 26 15 72 aa &G.P.T.c....&.r. Next, we construct the AEAD buffer Poly1305 Input: 000 f3 33 88 86 00 00 00 00 00 00 4e 91 00 00 00 00 .3........N..... 016 64 a0 86 15 75 86 1a f4 60 f0 62 c7 9b e6 43 bd d...u...`.b...C. 032 5e 80 5c fd 34 5c f3 89 f1 08 67 0a c7 6c 8c b2 ^.\.4\....g..l.. 048 4c 6c fc 18 75 5d 43 ee a0 9e e9 4e 38 2d 26 b0 Ll..u]C....N8-&. 064 bd b7 b7 3c 32 1b 01 00 d4 f0 3b 7f 35 58 94 cf ...<2.....;.5X.. 080 33 2f 83 0e 71 0b 97 ce 98 c8 a8 4a bd 0b 94 81 3/..q......J.... 096 14 ad 17 6e 00 8d 33 bd 60 f9 82 b1 ff 37 c8 55 ...n..3.`....7.U 112 97 97 a0 6e f4 f0 ef 61 c1 86 32 4e 2b 35 06 38 ...n...a..2N+5.8 128 36 06 90 7b 6a 7c 02 b0 f9 f6 15 7b 53 c8 67 e4 6..{j|.....{S.g. 144 b9 16 6c 76 7b 80 4d 46 a5 9b 52 16 cd e7 a4 e9 ..lv{.MF..R..... 160 90 40 c5 a4 04 33 22 5e e2 82 a1 b0 a0 6c 52 3e .@...3"^.....lR> 176 af 45 34 d7 f8 3f a1 15 5b 00 47 71 8c bc 54 6a .E4..?..[.Gq..Tj 192 0d 07 2b 04 b3 56 4e ea 1b 42 22 73 f5 48 27 1a ..+..VN..B"s.H'. 208 0b b2 31 60 53 fa 76 99 19 55 eb d6 31 59 43 4e ..1`S.v..U..1YCN 224 ce bb 4e 46 6d ae 5a 10 73 a6 72 76 27 09 7a 10 ..NFm.Z.s.rv'.z. 240 49 e6 17 d9 1d 36 10 94 fa 68 f0 ff 77 98 71 30 I....6...h..w.q0 256 30 5b ea ba 2e da 04 df 99 7b 71 4d 6c 6f 2c 29 0[.......{qMlo,) 272 a6 ad 5c b4 02 2b 02 70 9b 00 00 00 00 00 00 00 ..\..+.p........ 288 0c 00 00 00 00 00 00 00 09 01 00 00 00 00 00 00 ................ We calculate the Poly1305 tag and find that it matches Calculated Tag: 000 ee ad 9d 67 89 0c bb 22 39 23 36 fe a1 85 1f 38 ...g..."9#6....8 Finally, we decrypt the ciphertext Plaintext:: 000 49 6e 74 65 72 6e 65 74 2d 44 72 61 66 74 73 20 Internet-Drafts 016 61 72 65 20 64 72 61 66 74 20 64 6f 63 75 6d 65 are draft docume 032 6e 74 73 20 76 61 6c 69 64 20 66 6f 72 20 61 20 nts valid for a 048 6d 61 78 69 6d 75 6d 20 6f 66 20 73 69 78 20 6d maximum of six m 064 6f 6e 74 68 73 20 61 6e 64 20 6d 61 79 20 62 65 onths and may be 080 20 75 70 64 61 74 65 64 2c 20 72 65 70 6c 61 63 updated, replac 096 65 64 2c 20 6f 72 20 6f 62 73 6f 6c 65 74 65 64 ed, or obsoleted 112 20 62 79 20 6f 74 68 65 72 20 64 6f 63 75 6d 65 by other docume 128 6e 74 73 20 61 74 20 61 6e 79 20 74 69 6d 65 2e nts at any time. 144 20 49 74 20 69 73 20 69 6e 61 70 70 72 6f 70 72 It is inappropr 160 69 61 74 65 20 74 6f 20 75 73 65 20 49 6e 74 65 iate to use Inte 176 72 6e 65 74 2d 44 72 61 66 74 73 20 61 73 20 72 rnet-Drafts as r 192 65 66 65 72 65 6e 63 65 20 6d 61 74 65 72 69 61 eference materia 208 6c 20 6f 72 20 74 6f 20 63 69 74 65 20 74 68 65 l or to cite the 224 6d 20 6f 74 68 65 72 20 74 68 61 6e 20 61 73 20 m other than as 240 2f e2 80 9c 77 6f 72 6b 20 69 6e 20 70 72 6f 67 /...work in prog 256 72 65 73 73 2e 2f e2 80 9d ress./... """ k = bytes.fromhex( "1c 92 40 a5 eb 55 d3 8a \ f3 33 88 86 04 f6 b5 f0 \ 47 39 17 c1 40 2b 80 09 \ 9d ca 5c bc 20 70 75 c0" ) n = bytes.fromhex("00 00 00 00 01 02 03 04 05 06 07 08") ad = bytes.fromhex("f3 33 88 86 00 00 00 00 00 00 4e 91") tag = bytes.fromhex("ee ad 9d 67 89 0c bb 22 39 23 36 fe a1 85 1f 38") ct = bytes.fromhex( "64 a0 86 15 75 86 1a f4 60 f0 62 c7 9b e6 43 bd \ 5e 80 5c fd 34 5c f3 89 f1 08 67 0a c7 6c 8c b2 \ 4c 6c fc 18 75 5d 43 ee a0 9e e9 4e 38 2d 26 b0 \ bd b7 b7 3c 32 1b 01 00 d4 f0 3b 7f 35 58 94 cf \ 33 2f 83 0e 71 0b 97 ce 98 c8 a8 4a bd 0b 94 81 \ 14 ad 17 6e 00 8d 33 bd 60 f9 82 b1 ff 37 c8 55 \ 97 97 a0 6e f4 f0 ef 61 c1 86 32 4e 2b 35 06 38 \ 36 06 90 7b 6a 7c 02 b0 f9 f6 15 7b 53 c8 67 e4 \ b9 16 6c 76 7b 80 4d 46 a5 9b 52 16 cd e7 a4 e9 \ 90 40 c5 a4 04 33 22 5e e2 82 a1 b0 a0 6c 52 3e \ af 45 34 d7 f8 3f a1 15 5b 00 47 71 8c bc 54 6a \ 0d 07 2b 04 b3 56 4e ea 1b 42 22 73 f5 48 27 1a \ 0b b2 31 60 53 fa 76 99 19 55 eb d6 31 59 43 4e \ ce bb 4e 46 6d ae 5a 10 73 a6 72 76 27 09 7a 10 \ 49 e6 17 d9 1d 36 10 94 fa 68 f0 ff 77 98 71 30 \ 30 5b ea ba 2e da 04 df 99 7b 71 4d 6c 6f 2c 29 \ a6 ad 5c b4 02 2b 02 70 9b" ) msg = bytes.fromhex( "49 6e 74 65 72 6e 65 74 2d 44 72 61 66 74 73 20 \ 61 72 65 20 64 72 61 66 74 20 64 6f 63 75 6d 65 \ 6e 74 73 20 76 61 6c 69 64 20 66 6f 72 20 61 20 \ 6d 61 78 69 6d 75 6d 20 6f 66 20 73 69 78 20 6d \ 6f 6e 74 68 73 20 61 6e 64 20 6d 61 79 20 62 65 \ 20 75 70 64 61 74 65 64 2c 20 72 65 70 6c 61 63 \ 65 64 2c 20 6f 72 20 6f 62 73 6f 6c 65 74 65 64 \ 20 62 79 20 6f 74 68 65 72 20 64 6f 63 75 6d 65 \ 6e 74 73 20 61 74 20 61 6e 79 20 74 69 6d 65 2e \ 20 49 74 20 69 73 20 69 6e 61 70 70 72 6f 70 72 \ 69 61 74 65 20 74 6f 20 75 73 65 20 49 6e 74 65 \ 72 6e 65 74 2d 44 72 61 66 74 73 20 61 73 20 72 \ 65 66 65 72 65 6e 63 65 20 6d 61 74 65 72 69 61 \ 6c 20 6f 72 20 74 6f 20 63 69 74 65 20 74 68 65 \ 6d 20 6f 74 68 65 72 20 74 68 61 6e 20 61 73 20 \ 2f e2 80 9c 77 6f 72 6b 20 69 6e 20 70 72 6f 67 \ 72 65 73 73 2e 2f e2 80 9d" ) poly1305_input = bytes.fromhex( "f3 33 88 86 00 00 00 00 00 00 4e 91 00 00 00 00 \ 64 a0 86 15 75 86 1a f4 60 f0 62 c7 9b e6 43 bd \ 5e 80 5c fd 34 5c f3 89 f1 08 67 0a c7 6c 8c b2 \ 4c 6c fc 18 75 5d 43 ee a0 9e e9 4e 38 2d 26 b0 \ bd b7 b7 3c 32 1b 01 00 d4 f0 3b 7f 35 58 94 cf \ 33 2f 83 0e 71 0b 97 ce 98 c8 a8 4a bd 0b 94 81 \ 14 ad 17 6e 00 8d 33 bd 60 f9 82 b1 ff 37 c8 55 \ 97 97 a0 6e f4 f0 ef 61 c1 86 32 4e 2b 35 06 38 \ 36 06 90 7b 6a 7c 02 b0 f9 f6 15 7b 53 c8 67 e4 \ b9 16 6c 76 7b 80 4d 46 a5 9b 52 16 cd e7 a4 e9 \ 90 40 c5 a4 04 33 22 5e e2 82 a1 b0 a0 6c 52 3e \ af 45 34 d7 f8 3f a1 15 5b 00 47 71 8c bc 54 6a \ 0d 07 2b 04 b3 56 4e ea 1b 42 22 73 f5 48 27 1a \ 0b b2 31 60 53 fa 76 99 19 55 eb d6 31 59 43 4e \ ce bb 4e 46 6d ae 5a 10 73 a6 72 76 27 09 7a 10 \ 49 e6 17 d9 1d 36 10 94 fa 68 f0 ff 77 98 71 30 \ 30 5b ea ba 2e da 04 df 99 7b 71 4d 6c 6f 2c 29 \ a6 ad 5c b4 02 2b 02 70 9b 00 00 00 00 00 00 00 \ 0c 00 00 00 00 00 00 00 09 01 00 00 00 00 00 00" ) self.assertEqual((ct, tag), secret_box(k, n, msg, ad)) self.assertEqual(msg, secret_unbox(k, n, tag, ct, ad)) def test_bad_input(self) -> None: succeed = False # wrong key length try: secret_box(b"0", b"0" * NLEN, b"fail" * 32) except ValueError: succeed = True self.assertTrue(succeed, "klen") succeed = False # wrong nonce length try: secret_box(b"*" * KLEN, b"0", b"fail" * 32) except ValueError: succeed = True self.assertTrue(succeed, "nlen") # wrong tag length succeed = False try: secret_unbox(b"0" * KLEN, b"0" * NLEN, b"f" * 12, b"fail" * 32) except ValueError: succeed = True self.assertTrue(succeed, "tag len") # wrong nonce length succeed = False try: secret_unbox(b"0" * KLEN, b"0" * 5, b"f" * TAGLEN, b"fail" * 32) except ValueError: succeed = True self.assertTrue(succeed, "nonce len") # wrong key length succeed = False try: secret_unbox(b"0" * 31, b"0" * NLEN, b"f" * TAGLEN, b"fail" * 32) except ValueError: succeed = True self.assertTrue(succeed, "key len") # decryption failure succeed = False try: secret_unbox(b"8" * KLEN, b"8" * NLEN, b"3" * TAGLEN, b"fail" * 32) except Exception: succeed = True self.assertTrue(succeed, "Decryption failure") if __name__ == "__main__": unittest.main() pqconnect-1.2.1/test/test_client.py0000644000000000000000000000000014733452565016055 0ustar rootrootpqconnect-1.2.1/test/test_dns.py0000644000000000000000000000400014733452565015367 0ustar rootrootfrom multiprocessing import Event, Pipe, Process, synchronize from multiprocessing.connection import Connection from socket import getaddrinfo from unittest import TestCase from pqconnect.common.constants import A_RECORD from pqconnect.dnsproxy import DNSProxy from pqconnect.nft import NfqueueBuilder from scapy.all import DNSRR, IP, UDP INT_IP = "1.2.3.4" def dns_do(conn: Connection, cv: synchronize.Event) -> None: """Simple function that mimics the DNS mangling by the client""" while not cv.is_set(): data = conn.recv_bytes() p = IP(data) # replace all DNS answer records with internal ip for i in range(p.ancount): if DNSRR in p.an[i]: if p.an[i][DNSRR].type == A_RECORD: p.an[i][DNSRR].rdata = INT_IP # recompute checksums del p[IP].len del p[IP].chksum del p[UDP].len del p[UDP].chksum conn.send_bytes(bytes(p)) class DNSProxyTest(TestCase): def setUp(self) -> None: self.local_conn, self.proxy_conn = Pipe() self.end_cond = Event() self.proxy = DNSProxy(self.proxy_conn, "pqconnect_filter_test") self.proxy_proc = Process(target=self.proxy.run) self.dns_proc = Process( target=dns_do, args=(self.local_conn, self.end_cond) ) self.builder = NfqueueBuilder("test_table") def tearDown(self) -> None: self.proxy.close() def test_nftables_integration(self) -> None: """Tests that packets queued by the netfilterqueue rule from the NfqueueBuilder object are handled as espected. """ try: self.builder.build() self.proxy_proc.start() self.dns_proc.start() info = getaddrinfo("www.jlev.in", 12345) addr = info[0][4][0] self.assertEqual(addr, INT_IP) finally: self.proxy_proc.kill() self.end_cond.set() self.dns_proc.kill() self.builder.tear_down() pqconnect-1.2.1/test/test_dns_parse.py0000644000000000000000000000247114733452565016573 0ustar rootrootfrom unittest import TestCase from pqconnect.common.constants import DNS_ENCODED_HASH_LEN from pqconnect.dns_parse import parse_pq1_record class TestDNSParse(TestCase): def test_parse_pq1_record_without_ports(self) -> None: self.assertEqual( parse_pq1_record( "pq1u1hy1ujsuk258krx3ku6wd9rp96kfxm64mgct3s3j26udp57dbu1.pqconnect.net" ), ( b":<\x1f4\xc4:\x8a\x82\xe2\xed#j\xc3Y\xba5\x99\xd8\xfa4d\xba\x95\x07\x1eP\x18\xcdj9L\xe9", ), "Parsing without ports failed", ) def test_parse_pq1_record_with_ports(self) -> None: self.assertEqual( parse_pq1_record( "pq1u1hy1ujsuk258krx3ku6wd9rp96kfxm64mgct3s3j26udp57dbu1zzz1zzz1.pqconnect.net" ), ( b":<\x1f4\xc4:\x8a\x82\xe2\xed#j\xc3Y\xba5\x99\xd8\xfa4d\xba\x95\x07\x1eP\x18\xcdj9L\xe9", 65535, 65535, ), "Parsing with ports failed", ) def test_parse_wrong_alphabet(self) -> None: name = "pq1aeio1ujsuk258krx3ku6wd9rp96kfxm64mgct3s3j26udp57dbu1.pqconnect.net" components = name.split(".") self.assertEqual(len(components[0][3:]), DNS_ENCODED_HASH_LEN) self.assertEqual(parse_pq1_record(name), ()) pqconnect-1.2.1/test/test_handshake.py0000644000000000000000000000340314733452565016537 0ustar rootrootfrom unittest import TestCase from pqconnect.common.crypto import dh, ekem, skem from pqconnect.keys import PKTree from pqconnect.peer import Peer from pqconnect.pqcclient import PQCClientConnectionHandler from pqconnect.pqcserver import PQCServerHS class DummyServer: def is_tid_seen(self, tid: bytes) -> bool: """Return False so that the PQCServerHS completes the handshake""" return False class DummyClient: pass class DummyDevice: pass class HandshakeTest(TestCase): def setUp(self) -> None: self.skem = skem self.pqpk, self.pqsk = self.skem.keypair() self.npqpk, self.npqsk = dh.dh_keypair() self.client = DummyClient() self.device = DummyDevice() self.server = DummyServer() self.pktree = PKTree(self.pqpk, self.npqpk) def test_handshake_0rtt(self) -> None: e_sntrup_r, e_sntrupsk_r = ekem.keypair() e_x25519_r, e_x25519sk_r = dh.dh_keypair() mceliece_ct = skem.enc(self.pqpk) i = PQCClientConnectionHandler( Peer("1.2.3.4", "2.4.6.8", pkh=self.pktree.get_pubkey_hash()), self.device, self.client, mceliece_ct, ) i._s_x25519_r = self.npqpk i._e_x25519_r = e_x25519_r i._e_sntrup_r = e_sntrup_r r = PQCServerHS( self.server, self.pqsk, self.npqsk, e_sntrupsk_r, e_x25519sk_r, None, None, ) c0, c1, tag1, c3, tag3, tun_i = i.initiate_handshake_0rtt() tun_r = r.complete_handshake_0rtt(c0, c1, tag1, c3, tag3) self.assertEqual( tun_r.tunnel_recv(tun_i.tunnel_send(b"hello")), b"hello" ) tun_r.close() tun_i.close() pqconnect-1.2.1/test/test_iface.py0000644000000000000000000001232614733452565015664 0ustar rootrootfrom ipaddress import IPv4Network from multiprocessing import Pipe from select import select from signal import SIGALRM, alarm, signal from socket import AF_INET, SOCK_DGRAM, socket from threading import Event from time import sleep from unittest import TestCase from pqconnect.iface import ( check_overlapping_address, create_tun_interface, tun_listen, ) from scapy.all import IP, UDP class TestiFace(TestCase): def setUp(self) -> None: self.tunfile = create_tun_interface("iface_test", "10.55.42.254", 31) def tearDown(self) -> None: self.tunfile.close() def test_check_overlapping_addresses(self) -> None: vectors = [ { "address": "192.168.3.25", "prefix_len": 16, "addrs": [("192.168.1.1", 16)], "result": False, }, { "address": "192.168.3.25", "prefix_len": 32, "addrs": [("192.168.3.25", 32)], "result": False, }, { "address": "10.0.0.0", "prefix_len": 8, "addrs": [("10.255.255.255", 8)], "result": False, }, { "address": "192.168.3.25", "prefix_len": 16, "addrs": [("10.10.0.1", 16)], "result": True, }, { "address": "192.168.3.25", "prefix_len": 24, "addrs": [("192.168.1.1", 24)], "result": True, }, { "address": "10.10.0.1", "prefix_len": 24, "addrs": [ ("192.168.1.1", 20), ("10.10.10.1", 24), ("172.16.0.5", 12), ], "result": True, }, { "address": "10.10.0.1", "prefix_len": 16, "addrs": [ ("192.168.1.1", 20), ("10.10.10.1", 24), # overlap ("172.16.0.5", 12), ], "result": False, }, { "address": "10.10.128.1", "prefix_len": 23, "addrs": [ ("192.168.1.1", 20), ("10.10.129.1", 23), # overlap ("172.16.0.5", 12), ], "result": False, }, ] for v in vectors: addrs = [IPv4Network(nw, strict=False) for nw in v["addrs"]] self.assertEqual( check_overlapping_address( v["address"], v["prefix_len"], iface_addrs=addrs ), v["result"], ) def test_tun_listen(self) -> None: """Tests that tun_listen forwards packets in both directions. This test is synchronous, but alarm() is used to send a SIGALRM to kill the while loop in tun_listen """ try: # some test-specific setup: ## local is used by this test. remote is used by the tun_listen function local, remote = Pipe() ## signaling object and signal handler evt = Event() def handler(signum: int, frame: object) -> None: evt.set() signal(SIGALRM, handler) # Direction localhost > network ## python process sends a packet that routes to TUN dev s = socket(AF_INET, SOCK_DGRAM) s.sendto(b"hello", ("10.55.42.255", 12345)) ## the packet should have arrived at the TUN device. ## Run tun_listen() to read the packet from the TUN device ## and forward it to our 'local' pipe alarm(1) tun_listen(self.tunfile, remote, evt) ## read from the pipe while True: readable = select([local], [], [], 0.1) # fail if empty if not readable: print(readable) self.assertFalse(True) data = local.recv_bytes() p = IP(data) # If the OS wrote something else to the TUN device, skip and repeat if UDP not in p: continue self.assertEqual(bytes(p[UDP].payload), b"hello") break # Direction network > localhost ## reset event object evt.clear() ## Construct full echo packet with headers src = p.src dst = p.dst sport = p.sport dport = p.dport return_pkt = ( IP(src=dst, dst=src) / UDP(sport=dport, dport=sport) / b"howdy" ) ## send packet to be written to TUN device ## and thus forwarded back to the application socket local.send_bytes(bytes(return_pkt)) alarm(1) tun_listen(self.tunfile, remote, evt) data, _ = s.recvfrom(100) self.assertEqual(data, b"howdy") finally: # cleanup s.close() local.close() remote.close() pqconnect-1.2.1/test/test_kdf.py0000644000000000000000000001364714733452565015370 0ustar rootrootfrom unittest import TestCase, main from pqconnect.common.crypto import h as pqc_hash from pqconnect.common.crypto import stream_kdf class TestKDF(TestCase): def setUp(self) -> None: pass def tearDown(self) -> None: pass def test_stream_kdf(self) -> None: vectors = [ {"n": 0, "k": b"\x00" * 32, "inpt": None, "res": []}, { "n": 1, "k": b"\x00" * 32, "inpt": None, "res": [ b"v\xb8\xe0\xad\xa0\xf1=\x90@]j\xe5S\x86\xbd(\xbd\xd2\x19\xb8\xa0\x8d\xed\x1a\xa86\xef\xcc\x8bw\r\xc7" ], }, { "n": 2, "k": b"\x00" * 32, "inpt": None, "res": [ b"v\xb8\xe0\xad\xa0\xf1=\x90@]j\xe5S\x86\xbd(\xbd\xd2\x19\xb8\xa0\x8d\xed\x1a\xa86\xef\xcc\x8bw\r\xc7", b"\xdaAY|QWH\x8dw$\xe0?\xb8\xd8J7jC\xb8\xf4\x15\x18\xa1\x1c\xc3\x87\xb6i\xb2\xeee\x86", ], }, { "n": 20, "k": b"\x00" * 32, "inpt": None, "res": [ b"v\xb8\xe0\xad\xa0\xf1=\x90@]j\xe5S\x86\xbd(\xbd\xd2\x19\xb8\xa0\x8d\xed\x1a\xa86\xef\xcc\x8bw\r\xc7", b"\xdaAY|QWH\x8dw$\xe0?\xb8\xd8J7jC\xb8\xf4\x15\x18\xa1\x1c\xc3\x87\xb6i\xb2\xeee\x86", b"\x9f\x07\xe7\xbeUQ8z\x98\xba\x97|s-\x08\r\xcb\x0f)\xa0H\xe3ei\x12\xc6S>2\xeez\xed", b")\xb7!v\x9c\xe6NC\xd5q3\xb0t\xd89\xd51\xed\x1f(Q\n\xfbE\xac\xe1\n\x1fKyMo", b"-\t\xa0\xe6c&l\xe1\xae~\xd1\x08\x19h\xa0u\x8eq\x8e\x99{\xd3b\xc6\xb0\xc3F4\xa9\xa0\xb3]", b"\x01'7h\x1f{]\x0f(\x1e:\xfd\xe4X\xbc\x1es\xd2\xd3\x13\xc9\xcf\x94\xc0_\xf3qb@\xa2H\xf2", b"\x13 \xa0X\xd7\xb3Vk\xd5 \xda\xaa>\xd2\xbf\n\xc5\xb8\xb1 \xfb\x85's\xc3c\x974\xb4\\\x91\xa4", b"-\xd4\xcb\x83\xf8\x84\r.\xed\xb1X\x13\x10b\xac?\x1f,\xf8\xffm\xcd\x18V\xe8j\x1el1g\x16~", b"\xe5\xa6\x88t+G\xc5\xad\xfbY\xd4\xdfv\xfd\x1d\xb1\xe5\x1e\xe0;\x1c\xa9\xf8*\xca\x17>\xdb\x8br\x93G", b"N\xbe\x98\x0f\x90M\x10\xc9\x16D+G\x83\xa0\xe9\x84\x86\x0c\xb6\xc9W\xb3\x9c8\xed\x8fQ\xcf\xfa\xa6\x8aM", b"\xe0\x10%\xa3\x9cPEF\xb9\xdc\x14\x06\xa7\xeb(\x15\x1eQP\xd7\xb2\x04\xba\xa7\x19\xd4\xf0\x91\x02\x12\x17\xdb", b"\\\xf1\xb5\xc8LO\xa7\x1a\x87\x96\x10\xa1\xa6\x95\xacR|[VwJk\x8a!\xaa\xe8\x86\x85\x86\x8e\tL", b"\xf2\x9e\xf4\t\n\xf7\xa9\x0c\xc0~\x88\x17\xaaR\x87cy}<3+g\xcaK\xc1\x10d,!Q\xecG", b"\xee\x84\xcb\x8cB\xd8_\x10\xe2\xa8\xcb\x18\xc3\xb73_&\xe8\xc3\x9a\x12\xb1\xbc\xc1pqw\xb7a8s.", b"\xed\xaa\xb7M\xa1A\x0f\xc0U\xea\x06\x8c\x99\xe9&\n\xcb\xe37\xcf]>\x00\xe5\xb3#\x0f\xfe\xdb\x0b\x99\x07", b"\x87\xd0\xc7\x0e\x0b\xfeA\x98\xeagX\xddZa\xfb_\xec-\xf9\x81\xf3\x1b\xef\xe1S\xf8\x1d\x17\x16\x17\x84\xdb", b'\x1c\x88"\xd5<\xd1\xee}\xb526H(\xbd\xf4\x04\xb0@\xa8\xdc\xc5"\xf3\xd3\xd9\x9a\xecK\x80W\xed\xb8', b"P\t1\xa2\xc4-/\x0cW\x08G\x10\x0bWT\xda\xfc_\xbd\xb8\x94\xbb\xef\x1a-\xe1\xa0\x7f\x8b\xa0\xc4\xb9", b"\x190\x10f\xed\xbc\x05k{H\x1ez\x0cF){\xbbX\x9d\x9d\xa5\xb6u\xa6r>\x15.^c\xa4\xce", b"\x03N\x9e\x83\xe5\x8a\x01:\xf0\xe75/\xb7\x90\x85\x14\xe3\xb3\xd1\x04\r\x0b\xb9c\xb3\x95Kck_\xd4\xbf", ], }, { "n": 1, "k": b"\x00" * 32, "inpt": b"\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f" + b"\x00" * 16, "res": [ b"\x8ac\xc7\xf8\xae\x1fH\x11+-S\xe0r\x85b?\xde\xbf%\xcfc\xfa\xc4\xa8\x07\xe8\x89\xd2\x84\xef\x95N" ], }, ] self.assertTrue( all( [ stream_kdf(vec["n"], vec["k"], vec["inpt"]) == vec["res"] for vec in vectors ] ) ) def test_stream_kdf_wrong_key_type(self) -> None: try: stream_kdf(1, "h" * 32) except Exception: self.assertTrue(True) return self.assertTrue(False, "exception was not thrown") def test_stream_kdf_wrong_key_len(self) -> None: try: stream_kdf(1, b"h" * 2) except Exception: self.assertTrue(True) return self.assertTrue(False, "exception was not thrown") def test_stream_kdf_wrong_inpt_type(self) -> None: try: stream_kdf(1, b"h" * 32, "hi" * 16) except Exception: self.assertTrue(True) return self.assertTrue(False, "exception was not thrown") def test_stream_kdf_wrong_inpt_len(self) -> None: try: stream_kdf(1, b"h" * 32, b"hi" * 15) except Exception: self.assertTrue(True) return self.assertTrue(False, "exception was not thrown") def test_pqc_hash(self) -> None: vectors = [ ( b"", b"F\xb9\xdd+\x0b\xa8\x8d\x13#;?\xebt>\xeb$?\xcdR\xeab\xb8\x1b\x82\xb5\x0c'dn\xd5v/", ), ( b"0123456789abcdef", b"\xf2\x05\xe4H\xf1?u\xe2B\xc27\xac\x0c\x15\x05\xb0\x02\x0c\xde[\x8b\xd5\xe6\xf0\xb0\\DB\x99\x81\x7f\xf7", ), ( ( b"'Twas brillig, and the slithy toves\n" b"Did gyre and gimble in the wabe;\n" b"All mimsy were the borogoves,\n" b"And the mome raths outgrabe." ), b"s\x89\x98y\xf19H7NIo}*\x10\x16\x004W\x0c\x89\xca\x1bw\x82\x9c\x8e5U)dq\xb6", ), ] self.assertTrue(all([pqc_hash(a) == b for a, b in vectors])) if __name__ == "__main__": main() pqconnect-1.2.1/test/test_keygen.py0000644000000000000000000001225014733452565016073 0ustar rootrootimport stat from os import environ, getcwd, listdir from os import lstat as st from os import mkdir, remove, rmdir from os.path import basename, join from unittest import TestCase, main, mock from unittest.mock import Mock from click.testing import CliRunner from pqconnect.common.constants import CONFIG_PATH, DEFAULT_KEYPATH from pqconnect.keygen import main as m from pqconnect.keygen import save_keys, static_keygen class TestKeyGen(TestCase): def setUp(self) -> None: self.cli = CliRunner() @mock.patch("builtins.input", return_value="y") def test_save_keys_create_dir(self, mocked_input: Mock) -> None: with self.cli.isolated_filesystem(): self.assertTrue( save_keys("some_path", b"0", b"1", b"2", b"3", b"4") ) @mock.patch("builtins.input", return_value="") def test_save_keys_create_dir_decline(self, mocked_input: Mock) -> None: with self.cli.isolated_filesystem(): self.assertFalse( save_keys("some_path_new", b"0", b"1", b"2", b"3", b"4") ) @mock.patch("builtins.input", return_value="y") def test_file_perms(self, mocked_input: Mock) -> None: """Tests that keys are saved with correct umask""" path = "some_path" with self.cli.isolated_filesystem(): save_keys(path, b"0", b"1", b"2", b"3", b"4") for name in ["mceliece_sk", "x25519_sk", "session_key"]: print(f"\033[92m{name}:..\033[0m", end="") perms = st(join(path, name)) self.assertFalse( perms.st_mode & ( stat.S_IRGRP | stat.S_IWGRP | stat.S_IXGRP | stat.S_IWOTH | stat.S_IROTH | stat.S_IXOTH ) ) print("\033[92mGood\033[0m") for name in ["mceliece_pk", "x25519_pk"]: perms = st(join(path, name)) print(f"\033[92m{name}:..\033[0m", end="") perms = st(join(path, name)) self.assertFalse( perms.st_mode & ( stat.S_IWGRP | stat.S_IXGRP | stat.S_IWOTH | stat.S_IXOTH ) ) print("\033[92mGood\033[0m") @mock.patch("builtins.input", return_value="y") def test_save_keys(self, mocked_input: Mock) -> None: """Tests that keys are saved as intended""" path = "some_path" with self.cli.isolated_filesystem(): save_keys(path, b"0", b"1", b"2", b"3", b"4") try: with open(join(path, "mceliece_pk"), "rb") as f: self.assertEqual(f.read(), b"0") f.close() with open(join(path, "mceliece_sk"), "rb") as f: self.assertEqual(f.read(), b"1") f.close() with open(join(path, "x25519_pk"), "rb") as f: self.assertEqual(f.read(), b"2") f.close() with open(join(path, "x25519_sk"), "rb") as f: self.assertEqual(f.read(), b"3") f.close() with open(join(path, "session_key"), "rb") as f: self.assertEqual(f.read(), b"4") f.close() except FileNotFoundError as e: self.assertTrue(False, e) def test_static_keygen(self) -> None: """test that static_keygen succeeds""" with self.cli.isolated_filesystem(): self.assertTrue(static_keygen(".", 12345, 54321, False)) @mock.patch("builtins.input") def test_static_keygen_fail(self, mocked: Mock) -> None: """check that static keygen returns False if user has no directory write permissions """ path = "test_static_keygen_fail" pqcport = 12345 keyport = 54321 dns_only = False # new key path, this will prompt the function to ask to mkdir # say no mocked.return_value = "N" self.assertFalse( static_keygen(path, pqcport, keyport, dns_only), "should not be able to write keys to disk", ) # mkdir again so that the tearDown doesn't throw an exception mocked.return_value = "y" self.assertTrue( static_keygen(path, pqcport, keyport, dns_only), "should not be able to write keys to disk", ) @mock.patch("builtins.input") def test_click(self, mocked: Mock) -> None: mocked.side_effect = ["y", "12345", "54321", "y"] keypath = basename(DEFAULT_KEYPATH) configpath = basename(CONFIG_PATH) with self.cli.isolated_filesystem(): res = self.cli.invoke(m, ["-d", keypath, "-c", configpath]) self.assertEqual(res.exit_code, 0) # test dns_only flag res = self.cli.invoke(m, ["-d", keypath, "-c", configpath, "-D"]) self.assertEqual(res.exit_code, 0) if __name__ == "__main__": main() pqconnect-1.2.1/test/test_keys.py0000644000000000000000000001453314733452565015572 0ustar rootrootfrom base64 import standard_b64decode as b64 from os import getcwd, mkdir, remove, rmdir, urandom from os.path import join from unittest import TestCase, main, mock from pqconnect.common.crypto import dh, h, skem from pqconnect.keys import InvalidNodeException, PKTree from pymceliece import mceliece6960119, mceliece8192128 class TestKeys(TestCase): def setUp(self) -> None: self.pk, self.sk = skem.keypair() self.point, self.scalar = dh.dh_keypair() self.tree = PKTree(self.pk, self.point) def tearDown(self) -> None: pass def test___init__(self) -> None: """Tests that tree is constructed correctly by comparing to known vectors """ try: tree = PKTree() except Exception: self.assertTrue(False, "exception thrown but shouldn't have") try: tree = PKTree( "a" * skem.PUBLICKEYBYTES, "b" * dh.lib25519_dh_PUBLICKEYBYTES ) self.assertTrue(False, "Should have thrown exception") except TypeError: self.assertTrue(True) try: tree = PKTree(self.pk, self.point[:5]) self.assertTrue(False, "Should have thrown exception") except ValueError: self.assertTrue(True) try: tree = PKTree(self.pk[:5], self.point) self.assertTrue(False, "Should have thrown exception") except ValueError: self.assertTrue(True) def test_from_file(self) -> None: with mock.patch("builtins.open", create=True) as mock_open: mock_open.return_value = mock.MagicMock() m_file = mock_open.return_value.__enter__.return_value reads = [self.pk, self.point] m_file.read.side_effect = lambda: reads.pop(0) try: tree = PKTree.from_file("mceliece", "x25519") except: self.assertFalse(True) try: tree = PKTree.from_file( "definitely_doesn't_exist", "booasdlfkjasdf" ) except FileNotFoundError: self.assertTrue(True) return self.assertTrue(False, "Exception was not thrown") def test_get_node(self) -> None: try: self.tree.get_node(0, 0) self.assertTrue(True) except Exception: self.assertTrue(False) try: self.tree.get_node(1234, 1234) self.assertTrue(False, "should have raised ValueError") except InvalidNodeException: self.assertTrue(True) try: tree = PKTree() tree.get_node(0, 0) self.assertTrue(False, "Should have raise ValueError") except InvalidNodeException: self.assertTrue(True) def test_get_children_range(self) -> None: """Tests that get_children_range returns the range of nodes whose concatenated hashes create the node passed to the function Does this for all level 0, 1, and 2 nodes """ try: a, b = PKTree.get_children_range(4, 0) self.assertTrue(False, "error not raised") except ValueError: self.assertTrue(True) if skem.PUBLICKEYBYTES == mceliece8192128: struct = {0: 1, 1: 1, 2: 33} else: struct = {0: 1, 1: 1, 2: 26} try: for i in range(3): for j in range(struct[i]): childbts = b"" a, b = PKTree.get_children_range(i, j) for k in range(a, b): childbts += h(self.tree.get_node(i + 1, k)) self.assertEqual(childbts, self.tree.get_node(i, j)) except Exception as e: self.assertTrue(False, e) def test_get_pks(self) -> None: """Checks that the public keys returns from the tree equal what was passed in construction """ self.assertEqual(self.pk, self.tree.get_pqpk()) self.assertEqual(self.point, self.tree.get_npqpk()) tree = PKTree() self.assertEqual(b"", tree.get_pqpk()) self.assertEqual(b"", tree.get_npqpk()) def test_is_complete(self) -> None: self.assertTrue(self.tree.is_complete()) del self.tree._tree[2][5] self.assertFalse(self.tree.is_complete()) def test_get_subtree_packets_at_root(self) -> None: if skem.PUBLICKEYBYTES == mceliece8192128.PUBLICKEYBYTES: vecs = [ ((0, 0), [(0, 0), (1, 0)]), ((1, 0), [(1, 0)]), ((2, 0), [(2, 0)] + [(3, i) for i in range(36)]), ((2, 32), [(2, 32)] + [(3, i) for i in range(1152, 1179)]), ((3, 0), [(3, 0)]), ] else: vecs = [ ((0, 0), [(0, 0), (1, 0)]), ((1, 0), [(1, 0)]), ((2, 0), [(2, 0)] + [(3, i) for i in range(36)]), ((2, 25), [(2, 25)] + [(3, i) for i in range(900, 910)]), ((3, 0), [(3, 0)]), ] for inpt, output in vecs: a, b = inpt self.assertEqual( self.tree.get_subtree_packets_at_root(a, b), output ) try: self.tree.get_subtree_packets_at_root(20, 5) self.assertTrue(False, "should have thrown ValueError") except InvalidNodeException: self.assertTrue(True) try: self.tree.get_subtree_packets_at_root(3, 2500) self.assertTrue(False, "should have thrown ValueError") except InvalidNodeException: self.assertTrue(True) def test_insert_node(self) -> None: tree = PKTree() self.assertTrue(tree.insert_node(0, 0, self.tree.get_node(0, 0))) self.assertTrue(tree.insert_node(0, 0, self.tree.get_node(0, 0))) self.assertTrue(tree.insert_node(1, 0, self.tree.get_node(1, 0))) self.assertTrue(tree.insert_node(2, 0, self.tree.get_node(2, 0))) self.assertFalse(tree.insert_node(3, 123, self.tree.get_node(3, 123))) self.assertFalse(tree.insert_node(123, 123, b"hello")) def test_insert_wrong_root(self) -> None: tree = PKTree() a = urandom(32) b = urandom(32) self.assertTrue(tree.insert_node(0, 0, a)) self.assertFalse(tree.insert_node(0, 0, b)) if __name__ == "__main__": main() pqconnect-1.2.1/test/test_keyserver.py0000644000000000000000000001253114733452565016632 0ustar rootrootimport io import socket from random import randint from time import time from typing import Any, Callable, Dict from unittest import TestCase, main from unittest.mock import mock_open, patch from pqconnect.common.constants import MCELIECE_PK_PATH, X25519_PK_PATH from pqconnect.common.crypto import dh, ekem, skem from pqconnect.common.util import round_timestamp from pqconnect.keys import PKTree from pqconnect.keyserver import KeyServer from pqconnect.keystore import ( EphemeralKey, EphemeralPrivateKey, EphemeralPrivateKeystore, Keystore, ) from pqconnect.request import ( EphemeralKeyRequest, EphemeralKeyResponse, StaticKeyRequest, StaticKeyResponse, ) class TestKeyServer(TestCase): def setUp(self) -> None: self._s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self._priv_keystore = EphemeralPrivateKeystore(time()) self._pub_keystore = self._priv_keystore.get_public_keystore() self._keyport = randint(1025, 1 << 16) def open_keyfiles(name: str, mode: str) -> io.BytesIO: spk, _ = skem.keypair() sdh, _ = dh.dh_keypair() if name == "mceliece": return io.BytesIO(spk) else: return io.BytesIO(sdh) with patch("builtins.open", new=open_keyfiles) as mock_thing: self._keyserver = KeyServer("mceliece", "x25519", self._keyport) self._keyserver.set_keystore(self._pub_keystore) self._keyserver.start() def tearDown(self) -> None: self._priv_keystore.close() self._pub_keystore.close() self._keyserver.close() self._s.close() def test_ephemeral_request_response(self) -> None: req = EphemeralKeyRequest() self._s.settimeout(1) self._s.sendto(bytes(req), ("localhost", self._keyport)) data, _ = self._s.recvfrom(4096) ntru, ecc = self._pub_keystore.get_current_keys().public_keys() resp = EphemeralKeyResponse(pqpk=ntru, npqpk=ecc) self.assertEqual(data, bytes(resp)) def test_static_request_response(self) -> None: tree = PKTree() for i in tree.get_structure().keys(): for j in range(tree.get_structure()[i]): req = StaticKeyRequest(i, j) self._s.sendto(bytes(req), ("localhost", self._keyport)) data, _ = self._s.recvfrom(4096) resp = StaticKeyResponse(payload=data) self.assertTrue(tree.insert_node(i, j, resp.keydata)) class TestEphemeralKey(TestCase): def setUp(self) -> None: self.privkey = EphemeralPrivateKey(int(time())) def tearDown(self) -> None: pass def test_clear(self) -> None: sntrup, ecc = self.privkey.get_secret_keys() self.assertNotEqual(sntrup, b"\x00" * len(sntrup)) self.assertNotEqual(ecc, b"\x00" * len(ecc)) self.privkey.clear() self.assertEqual(sntrup, b"\x00" * len(sntrup)) self.assertEqual(ecc, b"\x00" * len(ecc)) def test_copy(self) -> None: copy = self.privkey.copy() sntrup_a, ecc_a = self.privkey.get_secret_keys() sntrup_b, ecc_b = copy.get_secret_keys() self.assertEqual(sntrup_a, sntrup_b) self.assertEqual(ecc_a, ecc_b) self.privkey.clear() self.assertNotEqual(sntrup_b, sntrup_a) self.assertNotEqual(ecc_b, ecc_a) class TestKeystore(TestCase): def setUp(self) -> None: self.keystore = Keystore("test") self.keystore.start() def tearDown(self) -> None: self.keystore.close() def test_add_current_keys(self) -> None: sntrup_pk, sntrup_sk = ekem.keypair() ecc_pk, ecc_sk = dh.dh_keypair() now = round_timestamp(time()) key = EphemeralKey(now, sntrup_pk, ecc_pk) self.keystore.add(key) key2 = self.keystore.get_current_keys() self.assertEqual(key, key2) def test_prune_old_keys(self) -> None: sntrup_pk, sntrup_sk = ekem.keypair() ecc_pk, ecc_sk = dh.dh_keypair() key = EphemeralKey(0, sntrup_pk, ecc_pk) self.keystore.add(key) try: self.assertEqual(key, self.keystore.get_store()[0]) except Exception: self.assertTrue(False) self.keystore._prune_old_keys(test=True) try: key = self.keystore.get_store()[0] self.assertTrue(False, "key shouldn't exist") except Exception: self.assertTrue(True) class TestEphemeralPrivateKeystore(TestCase): def setUp(self) -> None: self.keystore = EphemeralPrivateKeystore(int(time())) def tearDown(self) -> None: self.keystore.close() def test_delete(self) -> None: now = round_timestamp(time()) key = EphemeralPrivateKey(now) self.keystore.add(key) try: self.assertEqual(self.keystore.get_store()[now], key) except Exception: G self.assertTrue(False, "could not get key") self.assertTrue(self.keystore.delete(key)) try: new_key = self.keystore.get_store()[now] self.assertEqual(key, new_key) self.assertTrue(False, "key should not exist") except KeyError: self.assertTrue(True) except Exception: self.assertTrue(False, "key should not exist") if __name__ == "__main__": main() pqconnect-1.2.1/test/test_nfq_builder.py0000644000000000000000000001353614733452565017113 0ustar rootrootimport json from random import randint from unittest import TestCase from nftables import Nftables from pqconnect.nft import NfqueueBuilder class NfqueueBuilderTest(TestCase): def setUp(self) -> None: self.nft = Nftables() self.nft.set_json_output(True) self.table_name = "test" self.builder = NfqueueBuilder(self.table_name) def tearDown(self) -> None: pass def test_add_table(self) -> None: """Tests that a table is correctly added to the ruleset. Fails if an error is thrown during table creation or if the table cannot be found in the ruleset """ try: self.builder._add_table(self.table_name) except Exception as e: self.assertTrue(False, e) success = False try: ruleset = dict(json.loads(self.nft.cmd("list ruleset")[1]))[ "nftables" ] for rule in ruleset: if "table" in rule.keys(): if self.table_name == rule["table"]["name"]: success = True break self.assertTrue(success) except Exception: pass finally: self.nft.cmd(f"delete table inet {self.table_name}") def test_add_chain(self) -> None: """Tests that input chain is correctly added to the test table. Fails if an error is thrown during chain creation or if the chain cannot be found in the ruleset """ try: self.builder._add_table(self.table_name) self.builder._add_input_filter_chain(self.table_name, 0) except Exception as e: self.nft.cmd(f"delete table inet {self.table_name}") self.assertTrue(False, e) success = False try: ruleset = dict(json.loads(self.nft.cmd("list ruleset")[1]))[ "nftables" ] for rule in ruleset: if "chain" in rule.keys(): if ( "input" == rule["chain"]["name"] and self.table_name == rule["chain"]["table"] ): success = True break self.assertTrue(success) except Exception: pass finally: self.nft.cmd(f"delete table inet {self.table_name}") def test_add_queue_rule(self) -> None: """Tests that queue rule is correctly added to the test table input chain. Fails if an error is thrown during rule creation or if the rule cannot be found in the ruleset """ queue_num = randint(0, 100) try: self.builder._add_table(self.table_name) self.builder._add_input_filter_chain(self.table_name, 0) self.builder._add_dns_queue_rule(self.table_name, queue_num) except Exception as e: self.nft.cmd(f"delete table inet {self.table_name}") self.assertTrue(False, e) success = False try: rules = dict(json.loads(self.nft.cmd("list ruleset")[1]))[ "nftables" ] self.assertTrue(all([isinstance(rule, dict) for rule in rules])) for rule in rules: if isinstance(rule, dict) and "rule" in rule.keys(): if ( "input" == rule["rule"]["chain"] and self.table_name == rule["rule"]["table"] and {"queue": {"num": queue_num}} in rule["rule"]["expr"] and { "match": { "op": "==", "left": { "payload": { "protocol": "udp", "field": "sport", } }, "right": 53, } } in rule["rule"]["expr"] ): success = True break self.assertTrue(success) except Exception as e: self.assertTrue(False, e) finally: self.nft.cmd(f"delete table inet {self.table_name}") def test_build(self) -> None: """tests the full build method""" try: queue_num = self.builder.build() success = False rules = dict(json.loads(self.nft.cmd("list ruleset")[1]))[ "nftables" ] self.assertTrue(all([isinstance(rule, dict) for rule in rules])) for rule in rules: if "rule" in rule.keys(): if ( "input" == rule["rule"]["chain"] and self.builder.table_name == rule["rule"]["table"] and {"queue": {"num": queue_num}} in rule["rule"]["expr"] and { "match": { "op": "==", "left": { "payload": { "protocol": "udp", "field": "sport", } }, "right": 53, } } in rule["rule"]["expr"] ): success = True break self.assertTrue(success) except Exception as e: self.assertTrue(False, e) finally: self.builder.tear_down() pqconnect-1.2.1/test/test_peer.py0000644000000000000000000000713214733452565015547 0ustar rootrootfrom unittest import TestCase from pqconnect.common.crypto import dh, randombytes, skem from pqconnect.peer import Peer, PeerState from pqconnect.tunnel import TunnelSession class TestPeer(TestCase): def setUp(self) -> None: self.tid = randombytes(32) self.t1_send_root = randombytes(32) self.t1_recv_root = randombytes(32) self.t2_send_root = bytes( [self.t1_recv_root[i] for i in range(len(self.t1_recv_root))] ) self.t2_recv_root = bytes( [self.t1_send_root[i] for i in range(len(self.t1_send_root))] ) self.my_tun = TunnelSession( self.tid, self.t1_send_root, self.t1_recv_root ) self.their_tun = TunnelSession( self.tid, self.t2_send_root, self.t2_recv_root ) self.ip_addr = "1.2.3.4" self.ip_addr2 = "2.3.4.5" self.peer = Peer(self.ip_addr, self.ip_addr2) self.peer.set_tunnel(self.my_tun) def tearDown(self) -> None: self.my_tun.close() self.their_tun.close() def test_init(self) -> None: try: peer = Peer("1", self.ip_addr) self.assertTrue(False, "exception not raised") except ValueError: self.assertTrue(True) try: peer = Peer(self.ip_addr, "1") self.assertTrue(False, "exception not raised") except ValueError: self.assertTrue(True) mceliece = randombytes(skem.PUBLICKEYBYTES) x25519 = randombytes(dh.lib25519_dh_PUBLICKEYBYTES) # Correct example try: peer = Peer( self.ip_addr, self.ip_addr2, pkh=b"5" * 52, mceliece_pk=mceliece, x25519_pk=x25519, port=12345, ) self.assertTrue(True) except Exception as e: self.assertTrue(False, e) def test_state_machine(self) -> None: """Tests that the peer state reflects the state machine""" peer = Peer(self.ip_addr, self.ip_addr) self.assertEqual(peer.get_state(), PeerState.NEW) peer.set_tunnel(self.my_tun) self.assertEqual(peer.get_state(), PeerState.ESTABLISHED) ct = self.their_tun.tunnel_send(b"hello") peer.decrypt(ct) self.assertEqual(peer.get_state(), PeerState.ALIVE) ct = self.their_tun.tunnel_send(b"hello") self.my_tun.close() # mimic a timeout self.assertFalse(self.my_tun.is_alive()) peer.decrypt(ct) self.assertEqual(peer.get_state(), PeerState.EXPIRED) peer.error() self.assertEqual(peer.get_state(), PeerState.ERROR) peer.close() self.assertEqual(peer.get_state(), PeerState.CLOSED) def test_get_pkh(self) -> None: """Tests that get_pkh returns the pkh from the constructor""" pkh = randombytes(32) peer = Peer(self.ip_addr, self.ip_addr2) self.assertEqual(peer.get_pkh(), b"") peer = Peer(self.ip_addr2, self.ip_addr, pkh=pkh) self.assertEqual(peer.get_pkh(), pkh) def test_encrypt_decrypt(self) -> None: peer = Peer(self.ip_addr, self.ip_addr) peer.set_tunnel(self.my_tun) peer_symmetric = Peer("1.2.3.4", "10.10.0.6") # ip addr doesn't matter peer_symmetric.set_tunnel(self.their_tun) hello0 = b"hello0" hello1 = b"hello1" ct = peer.encrypt(hello0) self.assertNotEqual(ct, b"") self.assertEqual(peer_symmetric.decrypt(ct), hello0) ct = peer_symmetric.encrypt(hello1) self.assertEqual(peer.decrypt(ct), hello1) pqconnect-1.2.1/test/test_pqcclient.py0000644000000000000000000002043314733452565016575 0ustar rootrootfrom multiprocessing import Event, Pipe from time import time from unittest import TestCase, main from pqconnect.common.constants import KEYPORT, NUM_PREKEYS from pqconnect.common.crypto import dh, skem from pqconnect.common.util import base32_decode from pqconnect.iface import create_tun_interface from pqconnect.keys import PKTree from pqconnect.log import logger from pqconnect.peer import Peer from pqconnect.pqcclient import PQCClient, PQCClientConnectionHandler from scapy.all import IP reg_pkt = bytes.fromhex( "4500019a035900004011c1be08080808ac13f8180035ae5e018674aaa514818000010003000400050c646574656374706f7274616c0766697265666f7803636f6d0000010001c00c00050001000000b9001e0c646574656374706f7274616c0470726f64066d6f7a617773036e657400c03600050001000001cb00290470726f640c646574656374706f7274616c0470726f6408636c6f75646f7073066d6f7a676370c04fc06000010001000002690004226bdd52c08000020001000086c5001c0b6e732d636c6f75642d63310d676f6f676c65646f6d61696e73c021c08000020001000086c5000e0b6e732d636c6f75642d6334c0b1c08000020001000086c5000e0b6e732d636c6f75642d6332c0b1c08000020001000086c5000e0b6e732d636c6f75642d6333c0b1c0cd00010001000004ec0004d8ef266cc0e7001c00010000012c00102001486048020034000000000000006cc101001c00010001d05900102001486048020036000000000000006cc0cd001c00010000349b00102001486048020038000000000000006c0000291000000000000000" ) pkh_pkt = bytes.fromhex( "".join( [ "450000ad1b9f4000401198dcc0a80201c0a80273", # IP header "0035e8460099c287", # UDP header "ff3e8180000100020000000103777777097071636f6e6e656374036e65740000010001c00c00050001000000010047377071317531687931756a73756b3235386b7278336b7536776439727039366b66786d36346d6763743373336a3236756470353764627531097071636f6e6e656374036e657400c02f000100010000001f0004839b457e0000291000000000000000", ] ) ) keyserver_txt_pkt = bytes.fromhex( "450000a1b69d40004011fd78c0a80201c0a802e40035dcd1008db541df5381800001000100000001026b7337707131713475716835746335397876646a7564366d78353138753066636b73666e6477736d67636e62786a38776d7970666e6d6a667830046a6c657602696e0000100001c00c0010000100000258001a1969703d3139352e3230312e33352e3132313b703d34323432350000290200000000000000" ) class DummyClient: def generate_prekeys(self) -> None: pass class DummyDevice: pass class PQCClientTest(TestCase): def setUp(self) -> None: self.dev_name = "pqc_test" self.TUN = create_tun_interface(self.dev_name, "10.59.0.0", 16) self.cli_dns_conn, self.remote_dns_conn = Pipe() self.cli_tun_conn, self.remote_tun_conn = Pipe() self.end_cond = Event() self.cli = PQCClient( 12345, self.cli_tun_conn, self.cli_dns_conn, self.end_cond, dev_name=self.dev_name, ) self.x25519_pk, self.x25519_sk = dh.dh_keypair() self.mceliece_pk, self.mceliece_sk = skem.keypair() self.pk_tree = PKTree(self.mceliece_pk, self.x25519_pk) self.pkh = self.pk_tree.get_pubkey_hash() def tearDown(self) -> None: self.cli.stop() self.TUN.close() def test_generate_prekeys(self) -> None: """Test that the function creates a cache of valid mceliece ciphertexts iff none already exist """ # prekeys should be empty at initialization self.assertEqual(self.cli._prekeys, {}) # Generate prekeys and ensure it returns True, indicating it generated # keys now = int(time()) res = self.cli.generate_prekeys( self.pkh, self.mceliece_pk, self.x25519_pk, timestamp=now ) self.assertTrue(res) # make sure it created NUM_PREKEYS keys self.assertEqual( len(self.cli._prekeys[self.pkh]["mceliece"]), NUM_PREKEYS ) # make sure timestamp was assigned correctly self.assertEqual(self.cli._prekeys[self.pkh]["ts"], now) # make sure x25519 public key was stored correctly self.assertEqual(self.cli._prekeys[self.pkh]["x25519"], self.x25519_pk) # make sure more prekeys are not generated when they already exist self.assertFalse( self.cli.generate_prekeys( self.pkh, self.mceliece_pk, self.x25519_pk ) ) # make sure the prekeys (mceliece ciphertexts) are decapsulating as # expected for _ in range(NUM_PREKEYS): ct, k = self.cli._prekeys[self.pkh]["mceliece"].pop() self.assertEqual(skem.dec(ct, self.mceliece_sk), k) # make sure that new prekeys are made correctly after using up all the # existing ones now = int(time()) res = self.cli.generate_prekeys( self.pkh, self.mceliece_pk, self.x25519_pk, timestamp=now ) self.assertTrue(res) # make sure timestamp is updated correctly self.assertEqual(self.cli._prekeys[self.pkh]["ts"], now) def test_get_pk_hash(self) -> None: """Tests that _get_pk_hash correctly parses a public key hash from a DNS response packet """ self.assertEqual( self.cli._get_pk_hash(IP(pkh_pkt)), ( base32_decode( "u1hy1ujsuk258krx3ku6wd9rp96kfxm64mgct3s3j26udp57dbu1" ), ), ) self.assertEqual(self.cli._get_pk_hash(IP(reg_pkt)), ()) self.assertEqual( self.cli._get_pk_hash(IP(b"asd;fklajsd;fklajsd;fljasdf")), () ) def test_get_addrs(self) -> None: """Tests that all IPv4 addresses are returned from a DNS response packet """ self.assertEqual(self.cli._get_addrs(IP(pkh_pkt)), ["131.155.69.126"]) self.assertEqual(self.cli._get_addrs(IP(reg_pkt)), ["34.107.221.82"]) def test__get_cname(self) -> None: """Tests that the cname from a DNS response is correctly parsed""" self.assertEqual( PQCClient._get_domain_name(IP(pkh_pkt)), "pq1u1hy1ujsuk258krx3ku6wd9rp96kfxm64mgct3s3j26udp57dbu1.pqconnect.net.", ) def test_dns_handle(self) -> None: packet = bytes.fromhex( "450000b6025a40004011b1a7c0a80201c0a802e40035de" "c000a290a29dea8180000100020000000103777777046a" "6c657602696e0000010001c00c000500010000012c003a" "37707131396867743133747075356a7076676c746e776e" "753033787574756c71666738726c686870763163353833" "346e3734626d32327831c010c029000100010000012c00" "04c3c9237900002904d000000000001c000a00180a7c80" "2684a0b0135b96a3f5648841c70350d61665f76a36" ) pkt, peer = self.cli._dns_handle(pkh_pkt) self.assertNotEqual(peer, None) self.assertEqual(peer.get_internal_ip(), "10.59.0.2") class PQCClientConnectionHandlerTest(TestCase): def setUp(self) -> None: mceliece_pk, mceliece_sk = skem.keypair() x25519_pk, x25519_sk = dh.dh_keypair() self.pktree = PKTree(mceliece_pk, x25519_pk) assert self.pktree.is_complete() pkh = base32_decode( "u1hy1ujsuk258krx3ku6wd9rp96kfxm64mgct3s3j26udp57dbu1" ) cname = "pq1u1hy1ujsuk258krx3ku6wd9rp96kfxm64mgct3s3j26udp57dbu1.pqconnect.net" self.peer = Peer("131.155.69.126", "10.10.0.2", pkh=pkh, cname=cname) self.dumbcli = DummyClient() self.dumbdev = DummyDevice() self.handler = PQCClientConnectionHandler( self.peer, self.dumbdev, self.dumbcli, ) def test__resolve_keyserver_address(self) -> None: """Tests that the keyserver ip and port are successfully obtained from a keyserver TXT record """ self.handler._resolve_keyserver_address() self.assertEqual( self.handler._peer.get_external_ip(), "131.155.69.126" ) self.assertEqual(self.handler._peer.get_keyport(), 42425) def test_get_static_key(self) -> None: logger.setLevel(9) self.handler._resolve_keyserver_address() self.assertTrue(self.handler._request_static_keys_paced()) def test_get_ephemeral_key(self) -> None: logger.setLevel(9) self.handler._resolve_keyserver_address() self.assertTrue(self.handler._request_ephemeral_keys()) def test_initiate_handshake_0rtt(self) -> None: pass if __name__ == "__main__": main() pqconnect-1.2.1/test/test_pqcserver.py0000644000000000000000000001227514733452565016632 0ustar rootrootimport logging import os import socket import time from multiprocessing import Pipe from typing import Any, Callable, Dict from unittest import TestCase from unittest.mock import mock_open, patch from pqconnect.common.constants import ( EPHEMERAL_KEY_REQUEST, INITIATION_MSG, MCELIECE_PK_PATH, X25519_PK_PATH, ) from pqconnect.common.crypto import dh, skem from pqconnect.iface import create_tun_interface from pqconnect.keys import PKTree from pqconnect.keyserver import KeyServer from pqconnect.keystore import EphemeralPrivateKeystore from pqconnect.log import logger from pqconnect.peer import Peer from pqconnect.pqcclient import PQCClientConnectionHandler from pqconnect.pqcserver import PQCServer from pqconnect.request import EphemeralKeyRequest def randombts(n: int) -> bytes: return os.urandom(n) class PQCServerTest(TestCase): def setUp(self) -> None: # PQCServer requires keyfiles during init. Just create a tmp file and # assign the keys later to make this more portable self.tun_file = create_tun_interface( "pqc_test_server", "10.10.0.1", 16 ) self.tmpfilename: str = "/tmp/tmp-" + randombts(8).hex() self.local_conn, self.remote_conn = Pipe() with open(self.tmpfilename, "wb") as f: f.write(b"0" * 32) self.pqcserver = PQCServer( self.tmpfilename, self.tmpfilename, self.tmpfilename, 12345, self.local_conn, dev_name="pqc_test_server", ) self.mceliece_pk, self.mceliece_sk = skem.keypair() self.x25519_pk, self.x25519_sk = dh.dh_keypair() self.pktree = PKTree(self.mceliece_pk, self.x25519_pk) self.session_key = randombts(32) # bad OOP practice but thanks, Python :) self.pqcserver.mceliece_sk = self.mceliece_sk self.pqcserver.x25519_sk = self.x25519_sk self.pqcserver.session_key = self.session_key # Set up an ephemeral keystore private_keystore = EphemeralPrivateKeystore(time.time()) public_keystore = private_keystore.get_public_keystore() def mmock_open(*args: list[Any], **kwargs: Dict[Any, Any]) -> Callable: if args[0] == MCELIECE_PK_PATH: return mock_open(read_data=self.mceliece_pk)(*args, **kwargs) elif args[0] == X25519_PK_PATH: return mock_open(read_data=self.x25519_pk)(*args, **kwargs) else: return mock_open with patch("builtins.open", mmock_open): self.keyserver = KeyServer() self.keyserver.set_keystore(public_keystore) self.keyserver.start() self.pqcserver.set_keystore(private_keystore) # Create a socket from which we send messages to the (key)server self.out_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) self.out_sock.settimeout(2.0) def tearDown(self) -> None: self.out_sock.close() self.keyserver.close() self.pqcserver.close() self.tun_file.close() os.remove(self.tmpfilename) def test_keyserver(self) -> None: logger.setLevel(logging.DEBUG) addr = ("localhost", 42425) request = EphemeralKeyRequest().payload self.out_sock.sendto(request, addr) data, _ = self.out_sock.recvfrom(4096) self.assertNotEqual(data, b"") def test_handshake_cannot_replay(self) -> None: """Submit the same handshake message twice, and assert that only one connection with the server is established""" p = Peer("1.2.3.4", "10.10.0.2", pkh=self.pktree.get_node(0, 0)) ephemeral_pk = self.keyserver._keystore.get_current_keys() sntrup_pk, eph_ecc_pk = ephemeral_pk.public_keys() mceliece_ct = skem.enc(self.mceliece_pk) client_ctx = PQCClientConnectionHandler( p, None, DummyClient(), mceliece_ct ) client_ctx._s_x25519_r = self.x25519_pk client_ctx._e_sntrup_r = sntrup_pk client_ctx._e_x25519_r = eph_ecc_pk hs_vals = client_ctx.initiate_handshake_0rtt() # close the tunnel so timers are cancelled tun = list(hs_vals)[-1] tun.close() hs_vals = list(hs_vals)[:-1] msg = INITIATION_MSG + b"".join(hs_vals) print("Initial handshake") self.pqcserver.complete_handshake(msg, ("1.2.3.4", 12345)) time.sleep(0.1) self.assertTrue( self.pqcserver.is_mceliece_ct_seen(hs_vals[0]), "mceliece ct not stored correctly", ) seen_time = list(self.pqcserver._seen_mceliece_cts.keys())[0] print("Replay handshake") self.pqcserver.complete_handshake(msg, ("1.2.3.4", 12345)) time.sleep(0.1) self.assertEqual( len(self.pqcserver._seen_mceliece_cts.keys()), 1, "seen mceliece ct unexpected length", ) self.assertEqual( list(self.pqcserver._seen_mceliece_cts.keys())[0], seen_time, "seen mceliece ct unexpected timestamp", ) class DummyClient: def __init__(self) -> None: pass def generate_prekeys(self, a: bytes, b: bytes, c: bytes) -> bool: return True pqconnect-1.2.1/test/test_request.py0000644000000000000000000001064514733452565016307 0ustar rootrootfrom unittest import TestCase from pqconnect.common.constants import EPHEMERAL_KEY_REQUEST from pqconnect.common.crypto import dh, ekem from pqconnect.request import ( EphemeralKeyRequest, EphemeralKeyResponse, KeyRequest, KeyRequestHandler, KeyResponseHandler, StaticKeyRequest, StaticKeyResponse, UnexpectedRequestException, UnpackException, ) class TestKeyRequest(TestCase): def setUp(self) -> None: pass def tearDown(self) -> None: pass def test_init(self) -> None: try: kr = KeyRequest(0) self.assertTrue(False, "wrong type") except TypeError: self.assertTrue(True) try: kr = KeyRequest(b"\x00\x00") self.assertTrue(True) except Exception: self.assertTrue(False) def test_pack(self) -> None: msg_type = b"\x01\xff" try: kr = KeyRequest(msg_type) kr._pack_bts([1, b"1"]) self.assertTrue(False, "Should have thrown a TypeError") except TypeError: self.assertTrue(True) try: kr._pack_bts(["1", b"1"]) self.assertTrue(False, "Should have thrown a TypeError") except TypeError: self.assertTrue(True) try: kr._pack_bts([b"hello", b"goodbye"]) self.assertEqual( kr.payload, msg_type + b"\x00\x05hello\x00\x07goodbye" ) except Exception: self.assertTrue(False) def test_unpack(self) -> None: msg_type = b"\x01\xf1" # no significance payload = msg_type + b"\x00\x05hello\x00\x07goodbye" payload_wrong0 = payload + b"\x00\x00" payload_wrong1 = payload + b"\x00\x01" payload_wrong2 = msg_type + b"\x00\x05hello\x00\x08goodbye" try: kr = KeyRequest(msg_type=msg_type, payload=payload) vals = kr._unpack_bts() self.assertEqual(vals[0], b"hello") self.assertEqual(vals[1], b"goodbye") self.assertEqual(len(vals), 2) except Exception: self.assertTrue(False) try: kr = KeyRequest(msg_type, payload_wrong0) vals = kr._unpack_bts() self.assertTrue(False, "Extraneous zero-length value undetected") except UnpackException: self.assertTrue(True) try: kr = KeyRequest(msg_type, payload_wrong1) vals = kr._unpack_bts() self.assertTrue(False, "payload too long") except UnpackException: self.assertTrue(True) try: kr = KeyRequest(msg_type, payload_wrong2) vals = kr._unpack_bts() self.assertTrue(False, "payload truncated") except UnpackException: self.assertTrue(True) class TestKeyRequestHandler(TestCase): def setUp(self) -> None: pass def tearDown(self) -> None: pass def test_request_handler(self) -> None: """Tests that correct request is returned""" skr = StaticKeyRequest(depth=2, pos=20).payload eph_request = EphemeralKeyRequest() ekr = bytes(eph_request) handler_request = KeyRequestHandler(skr).request() self.assertTrue(isinstance(handler_request, StaticKeyRequest)) self.assertEqual(handler_request.depth, 2) self.assertEqual(handler_request.pos, 20) handler_request1 = KeyRequestHandler(ekr).request() self.assertTrue( isinstance(handler_request1, EphemeralKeyRequest), type(handler_request1), ) class TestResponseHandler(TestCase): def setUp(self) -> None: pass def tearDown(self) -> None: pass def test_response(self) -> None: sreq = StaticKeyResponse(depth=2, pos=25, keydata=b"Hi") resp = KeyResponseHandler(sreq.payload).response() self.assertTrue(isinstance(resp, StaticKeyResponse)) ereq = EphemeralKeyResponse(pqpk=b"0" * ekem.pklen, npqpk=b"0" * 32) resp = KeyResponseHandler(ereq.payload).response() self.assertTrue(isinstance(resp, EphemeralKeyResponse)) class TestEphemeralRequestResponse(TestCase): def test_same_size(self) -> None: req = EphemeralKeyRequest() sntrup, _ = ekem.keypair() ecc, _ = dh.dh_keypair() resp = EphemeralKeyResponse(pqpk=sntrup, npqpk=ecc) self.assertEqual(len(bytes(req)), len(bytes(resp))) pqconnect-1.2.1/test/test_server.py0000644000000000000000000000703014733452565016117 0ustar rootrootimport importlib.metadata import os from random import choices from shutil import rmtree from signal import SIGALRM, alarm, signal from typing import Any, Callable, Dict from unittest import TestCase, main, mock from unittest.mock import Mock, mock_open, patch from click.testing import CliRunner from pqconnect.common.constants import ( MCELIECE_PK_PATH, MCELIECE_SK_PATH, SESSION_KEY_PATH, X25519_PK_PATH, X25519_SK_PATH, ) from pqconnect.common.crypto import dh, skem from pqconnect.log import logger from pqconnect.server import main as m def handle(signum: int, _: Any) -> None: raise KeyboardInterrupt class TestServer(TestCase): def setUp(self) -> None: self.r = CliRunner() with self.r.isolated_filesystem(temp_dir="/tmp/"): self.keydir = os.getcwd() sp, ss = skem.keypair() dp, ds = dh.dh_keypair() sk = os.urandom(32) for key, path in zip( [sp, ss, dp, ds, sk], map( os.path.basename, [ MCELIECE_PK_PATH, MCELIECE_SK_PATH, X25519_PK_PATH, X25519_SK_PATH, SESSION_KEY_PATH, ], ), ): with open(str(path), "wb") as f: f.write(key) def tearDown(self) -> None: rmtree(self.keydir) def test_normal_main(self) -> None: try: # Automatically kill process signal(SIGALRM, handle) alarm(6) res = self.r.invoke(m, ["-i", "pqc-test"]) except KeyboardInterrupt: self.assertEqual(res.exit_code, 0) def test_click_invalid_directory(self) -> None: """Tests custom key directory""" with self.r.isolated_filesystem(): res = self.r.invoke(m, ["-d", "magic"]) # invalid click input gives exit code 2 self.assertEqual(res.exit_code, 2) def test_version(self) -> None: res = self.r.invoke(m, ["--version"]) VERSION = importlib.metadata.version("pqconnect") self.assertEqual(res.output.strip().split(" ")[-1], str(VERSION)) def test_invalid_addr(self) -> None: """Check that invalid address throws a ValueError""" res = self.r.invoke(m, ["--addr", "hello"]) self.assertTrue(isinstance(res.exception, ValueError)) def test_verbose(self) -> None: """Check that verbose flags change logging level""" try: signal(SIGALRM, handle) alarm(3) res = self.r.invoke(m, ["-v"]) except KeyboardInterrupt: self.assertEqual(logger.getEffectiveLevel(), 10) def test_very_verbose(self) -> None: try: signal(SIGALRM, handle) alarm(3) res = self.r.invoke(m, ["-vv"]) except KeyboardInterrupt: self.assertEqual(logger.getEffectiveLevel(), 9) def test_missing_key(self) -> None: """Check that""" with self.r.isolated_filesystem(temp_dir="/tmp/"): with open("mceliece_pk", "wb") as f: f.write(b"0" * skem.PUBLICKEYBYTES) with open("x25519_pk", "wb") as f: f.write(b"0" * dh.lib25519_dh_PUBLICKEYBYTES) res = self.r.invoke(m, ["-d", "."]) self.assertEqual(res.exit_code, 1) with open("session_key", "wb") as f: f.write(b"0" * 32) if __name__ == "__main__": main() pqconnect-1.2.1/test/test_tundevice.py0000644000000000000000000004700114733452565016601 0ustar rootrootimport logging import sys from multiprocessing import Pipe from multiprocessing.connection import wait from os import urandom from queue import Queue from socket import AF_INET, SOCK_DGRAM, socket from sys import getrefcount from threading import Event, Thread from typing import Any from unittest import TestCase, main from unittest.mock import patch from pqconnect.common.constants import ( COOKIE_PREFIX, HANDSHAKE_FAIL, INITIATION_MSG, PQCPORT, TUNNEL_MSG, ) from pqconnect.cookie.cookiemanager import CookieManager from pqconnect.iface import create_tun_interface, tun_listen from pqconnect.peer import Peer from pqconnect.tundevice import TunDevice from pqconnect.tunnel import TunnelSession from scapy.all import DNS, IP, TCP, UDP logging.basicConfig( level=9, format="%(asctime)s,%(msecs)d %(levelname)s: %(message)s", datefmt="%H:%M:%S", stream=sys.stderr, ) EXT_IP = "127.0.0.1" INT_IP = "172.16.0.2" MY_IP = "172.16.0.1" PREFIX_LEN = 24 dev_name = "test_pqc0" class DumbServer: def complete_handshake(self, packet: Any, addr: Any) -> bool: return True result_queue: Queue = Queue() def send_bytes(cls: Any, bts: bytes) -> None: result_queue.put(bts) def sock_send(cls: Any, bts: bytes, addr: Any) -> None: result_queue.put((bts, addr)) class TestTunDevice(TestCase): def setUp(self) -> None: # create cookie_manager self.cookie_manager = CookieManager(urandom(32)) # create TUN device self.tun_file = create_tun_interface(dev_name, MY_IP, PREFIX_LEN) # create pipe for tun_listener <-> client tundevice communication self.p_conn, self.c_conn = Pipe() # Start tun_listener as a thread self.event = Event() self.tun_thread = Thread( target=tun_listen, args=(self.tun_file, self.p_conn, self.event) ) self.tun_thread.start() # Create client tun device self.dev = TunDevice( PQCPORT, server=DumbServer(), cookie_manager=self.cookie_manager, tun_conn=self.c_conn, dev_name=dev_name, ) def tearDown(self) -> None: self.event.set() self.tun_thread.join() self.p_conn.close() self.c_conn.close() self.tun_file.close() self.dev.close() def test_get_ip_from_iface(self) -> None: """Tests that function returns the correct information in the correct format """ self.assertEqual( self.dev._get_ip_from_iface(dev_name), (MY_IP, PREFIX_LEN) ) def test__pton(self) -> None: """Tests that _pton correctly returns the masked address as an integer""" self.dev._prefix_len = 16 self.assertEqual(self.dev._pton("1.2.3.4"), (3 << 8) + 4) self.dev._prefix_len = 24 self.assertEqual(self.dev._pton("1.2.3.4"), 4) self.dev._prefix_len = 20 self.assertEqual(self.dev._pton("1.2.3.4"), (3 << 8) + 4) self.dev._prefix_len = 20 self.assertEqual(self.dev._pton("1.2.131.4"), (3 << 8) + 4) self.dev._prefix_len = 16 self.assertEqual(self.dev._pton("1.2.131.4"), (131 << 8) + 4) def test__make_local_ipv4(self) -> None: self.dev._prefix_len = 12 self.assertEqual(self.dev._make_local_ipv4(0), "172.16.0.0") self.assertEqual(self.dev._make_local_ipv4(127), "172.16.0.127") self.assertEqual(self.dev._make_local_ipv4(256), "172.16.1.0") self.assertEqual(self.dev._make_local_ipv4(257), "172.16.1.1") self.assertEqual(self.dev._make_local_ipv4(65535), "172.16.255.255") self.assertEqual(self.dev._make_local_ipv4(1048575), "172.31.255.255") self.dev._prefix_len = 24 try: self.dev._make_local_ipv4(256) self.assertTrue(False, "Should have thrown an exception") except ValueError: self.assertTrue(True) def test_get_next_ip(self) -> None: """Tests that test_get_next_ip returns an IP with the subnet prefix and the suffix equal to dev._next_ip """ self.dev._prefix_len = 12 self.dev._next_ip = 5 self.assertEqual(self.dev.get_next_ip(), "172.16.0.5") self.dev._next_ip = 1005 self.assertEqual(self.dev.get_next_ip(), "172.16.3.237") def test__is_in_subnet(self) -> None: self.assertTrue(self.dev._is_in_subnet("10.10.0.1", 16, "10.10.0.1")) self.assertTrue(self.dev._is_in_subnet("10.10.0.1", 16, "10.10.127.1")) self.assertTrue(self.dev._is_in_subnet("10.10.0.1", 16, "10.10.128.1")) self.assertFalse( self.dev._is_in_subnet("10.10.0.1", 17, "10.10.128.1") ) self.assertTrue(self.dev._is_in_subnet("10.10.0.1", 15, "10.11.128.1")) self.assertFalse( self.dev._is_in_subnet("10.10.0.1", 16, "10.11.128.1") ) def test_add_peer(self) -> None: """Tests that add_peer successfully updates the state of the object""" # Server peer self.dev._server = None int_ip = self.dev.get_next_ip() peer = Peer("1.2.3.4", int_ip, pkh=b"A" * 32) peer.set_tunnel(TunnelSession(b"0" * 32, b"1" * 32, b"2" * 32)) self.dev.add_peer(peer) try: self.assertEqual(self.dev.get_peer_by_pkh(b"A" * 32), peer) self.assertNotEqual(self.dev.get_next_ip(), int_ip) except Exception: self.assertTrue(False) finally: peer.close() def test_remove_expired_peers(self) -> None: """Tests that peers without an active session are removed""" ext_ip1 = "1.2.3.4" ext_ip2 = "4.3.2.1" int_ip1 = "2.3.4.5" int_ip2 = "5.4.3.2" peer1 = Peer(ext_ip1, int_ip1, pkh=urandom(32)) peer2 = Peer(ext_ip2, int_ip2, pkh=urandom(32)) tun1 = TunnelSession(urandom(32), urandom(32), urandom(32)) tun2 = TunnelSession(urandom(32), urandom(32), urandom(32)) tid1 = tun1.get_tid() tid2 = tun2.get_tid() peer1.set_tunnel(tun1) self.assertEqual(peer1.get_tid(), tid1) peer2.set_tunnel(tun2) self.dev.add_peer(peer1) self.dev.add_peer(peer2) tun1.close() self.dev._end_cond.set() self.dev.remove_expired_peers() try: self.dev._tid2peer[tid1] self.assertTrue(False, "peer shouldn't exist") except KeyError: self.assertTrue(True) try: self.dev._ext2peer[ext_ip1] self.assertTrue(False, "peer shouldn't exist") except KeyError: self.assertTrue(True) try: self.dev._int2peer[int_ip1] self.assertTrue(False, "peer shouldn't exist") except KeyError: self.assertTrue(True) try: self.dev._tid2peer[tid2] self.assertTrue(True) except KeyError: self.assertTrue(False, "peer should exist") finally: tun2.close() peer2.close() peer1.close() def test_remove_peer(self) -> None: """Checks that after remove_peer is called on a peer that the reference count is equal to 1 (because we're checking it) """ peer = Peer("1.2.3.4", "2.3.4.5", pkh=b"a" * 32) peer.set_tunnel(TunnelSession(b"0" * 32, b"1" * 32, b"2" * 32)) refct = getrefcount(peer) self.dev.add_peer(peer) self.assertNotEqual(getrefcount(peer), refct) self.dev.remove_peer(peer) new_refct = getrefcount(peer) self.assertEqual( getrefcount(peer), refct, f"original ref_count: {refct}; new ref_count {new_refct}", ) def test_get_peer_by_pkh(self) -> None: """get method""" # Delete server since this only makes sense for the client self.dev._server = None try: peer = Peer("1.2.3.4", "2.3.4.5", pkh=b"hello") peer.set_tunnel(TunnelSession(b"0" * 32, b"1" * 32, b"2" * 32)) if not self.dev.add_peer(peer): raise Exception("Could not add peer") self.assertEqual(self.dev.get_peer_by_pkh(b"hello"), peer) except Exception as e: self.assertTrue(False, f"unable to find peer: {e}") finally: self.dev.remove_peer(peer) # Should except try: peer = Peer("1.2.3.4", "2.3.4.5") peer.set_tunnel(TunnelSession(b"0" * 32, b"1" * 32, b"2" * 32)) if not self.dev.add_peer(peer): raise Exception("Could not add peer") self.assertEqual(self.dev.get_peer_by_pkh(b"hello"), peer) except Exception as e: self.assertTrue(True, e) finally: self.dev.remove_peer(peer) # should fail try: peer = Peer("1.2.3.4", "2.3.4.5", pkh=b"asdlfkj") peer.set_tunnel(TunnelSession(b"0" * 32, b"1" * 32, b"2" * 32)) if not self.dev.add_peer(peer): raise Exception("Could not add peer") self.dev._pkh2peer.pop(b"asdlfkj") self.assertEqual(self.dev.get_peer_by_pkh(b"asdlfkj"), peer) except Exception as e: self.assertTrue(True, e) finally: self.dev.remove_peer(peer) def test__update_incoming_pk_addrs(self) -> None: """Makes sure packet is correctly mangled""" # Packet is a DNS response for a query to google udp_pkt = bytes.fromhex( "4500006f69f9400040114a4fc0a80201c0a802e400358f9c005bd82a786a" "8180000100010000000106676f6f676c6503636f6d0000010001c00c0001" "0001000000200004acd9a32e00002904d000000000001c000a0018dc75c2" "403b7b5ddc793ac4f9648680d2e418721a13b45a3a" ) tcp_pkt = bytes.fromhex( "450000341fdc4000400606d20a0a00010a0a0002bf1e01bbc705299f131b8" "122801001f5cf7900000101080af319cb02e533a82b" ) # UDP pkt = IP(udp_pkt) payload = bytes(pkt[UDP][DNS]) self.assertNotEqual(pkt.src, "1.2.3.4") self.assertNotEqual(pkt.dst, self.dev._my_ip) new_packet = self.dev._update_incoming_pk_addrs(udp_pkt, "1.2.3.4") new_pkt = IP(new_packet) new_payload = bytes(new_pkt[UDP][DNS]) self.assertEqual(payload, new_payload) self.assertEqual(new_pkt.src, "1.2.3.4") self.assertEqual(new_pkt.dst, self.dev._my_ip) # TCP pkt = IP(tcp_pkt) payload = bytes(pkt[TCP].payload) self.assertNotEqual(pkt.src, "1.2.3.4") self.assertNotEqual(pkt.dst, self.dev._my_ip) new_packet = self.dev._update_incoming_pk_addrs(tcp_pkt, "1.2.3.4") new_pkt = IP(new_packet) new_payload = bytes(new_pkt[TCP].payload) self.assertEqual(payload, new_payload) self.assertEqual(new_pkt.src, "1.2.3.4") self.assertEqual(new_pkt.dst, self.dev._my_ip) def test__generate_cookie(self) -> None: """Checks that cookie can be generated for a peer""" peer = Peer("1.2.3.4", "2.3.4.5") tunnel_session = TunnelSession(b"a" * 32, b"b" * 32, b"c" * 32) peer.set_tunnel(tunnel_session) cookie = self.dev._generate_cookie(peer) self.assertNotEqual(cookie, b"") peer.close() def test_prune_connection(self) -> None: """Tests that prune connection removes the least recently used session""" tids = [b"0" * 32] peer0 = Peer("1.2.3.4", "2.3.4.5") ts0 = TunnelSession(tids[0], b"1" * 32, b"2" * 32) peer0.set_tunnel(ts0) self.assertTrue(self.dev.add_peer(peer0)) # make sure prune is deterministic for _ in range(30): tid = urandom(32) peer = Peer(self.dev.get_next_ip(), self.dev.get_next_ip()) ts = TunnelSession(tid, urandom(32), urandom(32)) peer.set_tunnel(ts) tids.append(tid) self.assertTrue(self.dev.add_peer(peer)) peer.encrypt(b"hello") # Should remove peer0 self.dev._prune_connection() try: peer = self.dev._tid2peer[tids[0]] self.assertTrue(False, "peer should not exist") except Exception: self.assertTrue(True) for i in range(1, 31): try: peer = self.dev._tid2peer[tids[i]] self.assertTrue(True) self.dev.remove_peer(peer) except Exception: self.assertTrue(False, "peer should exist") def test_queue_incoming(self) -> None: """tests that packets are correctly sorted into their respective queues """ addr = self.dev._tunnel_sock.getsockname() s = socket(AF_INET, SOCK_DGRAM) s.bind(("127.0.0.1", 54321)) my_addr = s.getsockname() self.dev._server = 1 # just make server not None # handshake queue s.sendto(INITIATION_MSG, addr) self.dev._queue_incoming() self.assertEqual( self.dev._handshake_queue.get(), (INITIATION_MSG, my_addr) ) # cookie queue s.sendto(COOKIE_PREFIX, addr) self.dev._queue_incoming() self.assertEqual( self.dev._session_resume_queue.get(), (COOKIE_PREFIX, my_addr) ) # tunnel msg s.sendto(TUNNEL_MSG, addr) self.dev._queue_incoming() self.assertEqual(self.dev._recv_queue.get(), (TUNNEL_MSG, my_addr)) # handshake fail peer = Peer(my_addr[0], "10.0.0.5") peer.set_tunnel(TunnelSession(b"0" * 32, b"1" * 32, b"2" * 32)) self.dev.add_peer(peer) self.assertEqual(self.dev._ext2peer[my_addr[0]], peer) s.sendto(HANDSHAKE_FAIL, addr) self.dev._queue_incoming() try: self.dev.get_peer_by_pkh(b"0" * 32) self.assertTrue(False, "peer should have been removed") except Exception: self.assertTrue(True) peer.close() s.close() @patch("pqconnect.pqcserver.PQCServer", new="__main__.DumbServer") def test__process_handshake_from_queue(self) -> None: """Tests that queued handshake messages are processed correctly""" # handshake queue addr = self.dev._tunnel_sock.getsockname() s = socket(AF_INET, SOCK_DGRAM) # handshake queue s.sendto(INITIATION_MSG, addr) self.dev._queue_incoming() self.assertEqual( self.dev._handshake_queue.qsize(), 1, "Queue should not be empty" ) self.dev._process_handshake_from_queue() self.assertEqual( self.dev._handshake_queue.qsize(), 0, "Queue should be empty" ) s.close() def test__process_cookie_from_queue(self) -> None: """Tests that queued cookie messages are processed correctly""" tid = urandom(32) sr = b"B" * 32 rr = b"C" * 32 my_tun = TunnelSession(tid, sr, rr) peer = Peer("1.2.3.4", self.dev.get_next_ip()) peer.set_tunnel(my_tun) self.assertTrue(self.dev.add_peer(peer)) # Make sure peer exists try: peer0 = self.dev._tid2peer[tid] self.assertEqual(peer0.get_external_ip(), "1.2.3.4") except KeyError: self.assertTrue(False, "peer was not added correctly") cookie = self.dev._generate_cookie(peer) self.dev.remove_peer(peer0) # Make sure peer doesn't exist try: self.dev._tid2peer[tid] self.assertTrue(False, "Peer exists but shouldn't") except KeyError: self.assertTrue(True) self.dev._session_resume_queue.put( (cookie.bytes(), (peer.get_external_ip(), peer.get_pqcport())) ) self.assertTrue(self.dev._process_cookie_from_queue()) try: peer1 = self.dev._tid2peer[tid] self.assertEqual(peer.get_external_ip(), "1.2.3.4") peer1.close() except KeyError: self.assertTrue(False, "peer not found") finally: peer.close() @patch("multiprocessing.connection.Connection.send_bytes", new=send_bytes) def test__receive_from_queue(self) -> None: """Tests that queued session messages are processed correctly""" # Create some tunnel session objects ## TID tid = urandom(32) ## Root keys sr = urandom(32) their_rr = bytes([a for a in sr]) rr = urandom(32) their_sr = bytes([a for a in rr]) ## TunnelSession their_tun = TunnelSession(tid, their_sr, their_rr) tun = TunnelSession(tid, sr, rr) # Create peer peer = Peer("1.2.3.4", self.dev.get_next_ip()) peer.set_tunnel(tun) self.dev.add_peer(peer) # Create plaintext packet msg = IP(src=peer.get_internal_ip()) / UDP(sport=12345) / b"hello" msgbts = bytes(msg) # Enqueue encrypted packet self.assertEqual(self.dev._recv_queue.qsize(), 0) self.dev._recv_queue.put( ( their_tun.tunnel_send(msgbts), (peer.get_external_ip(), 12345), ) ) # Make sure result_queue is empty while True: try: _ = result_queue.get_nowait() except Exception: break self.dev._receive_from_queue() t = result_queue.get() self.assertEqual(bytes(IP(t)[UDP].payload), b"hello") self.dev.remove_peer(peer) their_tun.close() tun.close() peer.close() def test__queue_send_packet(self) -> None: """Tests that outgoing session messages are queued correctly""" peer = Peer("1.2.3.4", self.dev.get_next_ip()) print(f"internal IP: {peer.get_internal_ip()}") tun = TunnelSession(b"a" * 32, b"b" * 32, b"c" * 32) peer.set_tunnel(tun) self.dev.add_peer(peer) sock = socket(AF_INET, SOCK_DGRAM) sock.sendto(b"hello", (peer.get_internal_ip(), peer.get_pqcport())) self.dev._queue_send_packet() print(f"queue size: {self.dev._send_queue.qsize()}") t = IP(self.dev._send_queue.get()) while not t.proto == 17: conn = wait([self.dev._tun_conn], 1) if not conn: self.assertTrue(False, "did not queue packet") self.dev._queue_send_packet() t = IP(self.dev._send_queue.get()) self.assertEqual(bytes(t[UDP].payload), b"hello") sock.close() @patch("socket.socket.sendto", new=sock_send) def test__send_from_queue(self) -> None: """Tests that queued outgoing session messages are sent correctly""" tid = urandom(32) sr = urandom(32) their_rr = bytes([a for a in sr]) # create new object rr = urandom(32) their_sr = bytes([a for a in rr]) their_tun = TunnelSession(tid, their_sr, their_rr) tun = TunnelSession(tid, sr, rr) peer = Peer("1.2.3.4", self.dev.get_next_ip()) peer.set_tunnel(tun) self.dev.add_peer(peer) pkt = IP(dst=peer.get_internal_ip()) / UDP(dport=1234) / b"hello" self.dev._send_queue.put(bytes(pkt)) # make sure result_queue is empty while True: try: _ = result_queue.get_nowait() except Exception: break # send self.dev._send_from_queue() t, addr = result_queue.get() pkt = their_tun.tunnel_recv(t) their_tun.close() peer.close() self.assertEqual(bytes(IP(pkt)[UDP].payload), b"hello") if __name__ == "__main__": try: main() except KeyboardInterrupt: pass pqconnect-1.2.1/test/test_tunnel.py0000644000000000000000000003501414733452565016121 0ustar rootrootfrom time import monotonic, sleep from unittest import TestCase from pqconnect.common.constants import ( CHAIN_KEY_NUM_PACKETS, EPOCH_DURATION_SECONDS, MAX_CHAIN_LEN, MAX_EPOCHS, ) from pqconnect.common.crypto import randombytes from pqconnect.tunnel import ( EpochChain, PacketKey, ReceiveChain, SendChain, TunnelSession, ) class TestEpochChain(TestCase): """Test class for the Epoch Chain""" def setUp(self) -> None: self.now = int(monotonic()) - EPOCH_DURATION_SECONDS - 1 self.root_key = randombytes(32) self.epochChain = EpochChain(self.root_key, 0, start=self.now) def test_delete_packet_key(self) -> None: """Tests that packet keys are securely erased and deleted from the chain """ pk: PacketKey = self.epochChain.get_next_chain_key() self.epochChain.delete_packet_key(pk) self.assertEqual(pk.get_key(), b"\x00" * 32) try: self.epochChain.delete_packet_key(pk) self.assertTrue(False, "key was not removed from the chain") except ValueError: self.assertTrue(True) def test_chain_ratchet(self) -> None: """Tests that the chain state is correct after ratcheting""" next_chain_key = self.epochChain._next_chain_key self.assertNotEqual(next_chain_key, b"\x00" * 32) # assert that the counter is correct self.assertEqual(self.epochChain._ctr, CHAIN_KEY_NUM_PACKETS) self.assertEqual( len(self.epochChain._packet_keys), CHAIN_KEY_NUM_PACKETS ) self.epochChain.chain_ratchet() self.assertEqual(self.epochChain._ctr, 2 * CHAIN_KEY_NUM_PACKETS) self.assertEqual( len(self.epochChain._packet_keys), 2 * CHAIN_KEY_NUM_PACKETS ) # assert that the previous chain key has been erased during ratchet self.assertEqual(next_chain_key, b"\x00" * 32) # assert that the new chain key has been created next_chain_key = self.epochChain._next_chain_key self.assertNotEqual(next_chain_key, b"\x00" * 32) def test_get_packet_key(self) -> None: """Tests that get_packet_key returns the correct key in the chain""" key = self.epochChain.get_packet_key(0) self.assertEqual(key.get_ctr(), 0) key = self.epochChain.get_packet_key(1) self.assertEqual(key.get_ctr(), 1) key = self.epochChain.get_packet_key(50) self.assertEqual(key.get_ctr(), 50) try: key = self.epochChain.get_packet_key(1000) self.assertTrue( False, ( "Exception should be thrown due to too many" " (> MAX_CHAIN_LEN) keys in the chain" ), ) except ValueError: self.assertTrue(True) def test_get_next_key(self) -> None: """Tests that get_next_chain_key returns keys from the chain in order""" for i in range(100): key = self.epochChain.get_next_chain_key() self.assertEqual(key.get_ctr(), i) self.epochChain.delete_packet_key(key) def test_clear_chain(self) -> None: keys = [] for i in range(MAX_CHAIN_LEN): keys.append(self.epochChain.get_packet_key(i)) self.epochChain.clear() self.assertEqual(self.epochChain._next_chain_key, b"\x00" * 32) self.assertEqual(self.epochChain._next_epoch_key, b"\x00" * 32) for k in keys: self.assertEqual(k.get_key(), b"\x00" * 32) class TestSendChain(TestCase): def setUp(self) -> None: self.root_key = b"3" * 32 self.sendChain = SendChain(self.root_key) def test_epoch_ratchet(self) -> None: """Tests that the state is correct after an epoch ratchet occurs: - next_epoch_key is erased upon each epoch ratchet - epoch_no is correctly instantiated""" for i in range(100): self.assertEqual(self.sendChain.get_epoch_no(), i) next_epoch_key = self.sendChain._chain.get_next_epoch_key() next_chain_key = self.sendChain._chain._next_chain_key self.assertNotEqual(next_epoch_key, b"\x00" * 32) self.assertNotEqual(next_chain_key, b"\x00" * 32) self.sendChain.epoch_ratchet() self.assertEqual(next_epoch_key, b"\x00" * 32) self.assertEqual(next_chain_key, b"\x00" * 32) def test_expired_epoch_ratchet(self) -> None: """Tests that an epoch ratchet occurs if a key is requested after the ratchet expires """ # artificially turn the clock back 3 epochs key = self.sendChain.get_next_key() self.assertEqual(key.get_epoch(), 0) for i in range(1, 6): self.sendChain._chain._expire -= EPOCH_DURATION_SECONDS + 1 key = self.sendChain.get_next_key() self.assertEqual(key.get_epoch(), i) def test_correct_expire_after_ratchet(self) -> None: """When a new epoch begins the expiration time should be: min( (last epoch expiration time + EPOCH_DURATION_SECONDS), (now + EPOCH_DURATION_SECONDS) ) This allows us to sync forward with a peer whose clock is faster than ours (causing us to ratchet early) For example, if epoch zero, E_0, started at T=0, then the expiration time of E_0, Expire_0, = EPOCH_DURATION_SECONDS (30). If at T=20, we get a message from E_1, we'll sync forward, ratcheting to E_1. To allow for the possibility that our clock is slow, we don't set Expire_1 to 60 (Expire_0 + EPOCH_DURATION_SECONDS), but we instead set Expire_1 = 50. """ now = int(monotonic()) self.sendChain._chain._expire = now self.sendChain.epoch_ratchet() self.assertEqual(self.sendChain._epoch, 1) self.assertEqual( self.sendChain._chain._expire, now + EPOCH_DURATION_SECONDS ) self.sendChain._chain._expire = 0 self.sendChain.epoch_ratchet() self.assertEqual(self.sendChain._epoch, 2) self.assertEqual(self.sendChain._chain._expire, EPOCH_DURATION_SECONDS) class TestReceiveChain(TestCase): def setUp(self) -> None: self.root_key = b"\x00" * 32 self.recv_chain = ReceiveChain(self.root_key) def tearDown(self) -> None: self.recv_chain.clear() def test_delete_expired_epoch(self) -> None: """Tests that delete_expired_epoch""" self.assertEqual(self.recv_chain.get_chain_len(), 1) self.recv_chain.delete_expired_epoch(0) self.assertEqual(self.recv_chain.get_chain_len(), 0) def test_epoch_ratchet(self) -> None: """Tests that the state is correct after an epoch ratchet occurse: - the epoch counter is incremented - a new epochChain is added to the chain dictionary - a new deletion timer thread is added to the timer list""" chain_len = self.recv_chain.get_chain_len() epoch_no = self.recv_chain.get_epoch_no() timers_len = len(self.recv_chain._deletion_timers) self.recv_chain.epoch_ratchet() self.assertEqual(self.recv_chain.get_epoch_no(), epoch_no + 1) self.assertEqual(self.recv_chain.get_chain_len(), chain_len + 1) self.assertEqual(len(self.recv_chain._deletion_timers), timers_len + 1) def test_delete_packet_key(self) -> None: """Tests that packet keys are correctly deleted""" key = self.recv_chain.get_packet_key(1, 5) self.assertEqual(key.get_epoch(), 1) self.assertEqual(key.get_ctr(), 5) self.assertEqual( key.get_key(), ( b"'D;o\xd8\xd3\x8a\xff\x8e\x1d\xec\x89\xf9q\xc5" b"\xe2\xa7\xfe\x8ex\xe8pq-R\x7fL\xb3\xa8\xed\xa3u" ), ) self.recv_chain.delete_packet_key(key) self.assertEqual(key.get_key(), self.root_key) try: self.recv_chain.get_packet_key(1, 5) self.assertTrue(False, "packet key was not removed") except Exception: self.assertTrue(True) class TestTunnelSession(TestCase): def setUp(self) -> None: self.tid = randombytes(32) self.t1_send_root = randombytes(32) self.t1_recv_root = randombytes(32) self.t2_send_root = bytes( [self.t1_recv_root[i] for i in range(len(self.t1_recv_root))] ) self.t2_recv_root = bytes( [self.t1_send_root[i] for i in range(len(self.t1_send_root))] ) self.t1 = TunnelSession(self.tid, self.t1_send_root, self.t1_recv_root) self.t2 = TunnelSession(self.tid, self.t2_send_root, self.t2_recv_root) def tearDown(self) -> None: self.t1.close() self.t2.close() def test_get_tid(self) -> None: """Tests that tid is assigned correctly""" self.assertEqual(self.t1.get_tid(), self.tid) self.assertEqual(self.t2.get_tid(), self.tid) def test_get_send_key(self) -> None: """Tests that get_send_key returns keys and in the correct order""" key = self.t1.get_send_key() self.assertEqual(key.get_epoch(), 0) self.assertEqual(key.get_ctr(), 0) self.t1._send_chain.delete_packet_key(key) key = self.t1.get_send_key() self.assertEqual(key.get_epoch(), 0) self.assertEqual(key.get_ctr(), 1) self.t1._send_chain.delete_packet_key(key) self.t1.send_epoch_ratchet() key = self.t1.get_send_key() self.assertEqual(key.get_epoch(), 1) self.assertEqual(key.get_ctr(), 0) self.t1._send_chain.delete_packet_key(key) def test_send_receive(self) -> None: """This sends 1000 packets of random bytes in two different epochs and makes sure that the receiving tunnel successfully decrypts all packets """ for _ in range(1000): msg = randombytes(50) self.assertEqual( self.t1.tunnel_recv(self.t2.tunnel_send(msg)), msg, ) self.t1.send_epoch_ratchet() for _ in range(1000): msg = randombytes(612) self.assertEqual( self.t2.tunnel_recv(self.t1.tunnel_send(msg)), msg, ) self.t1.send_epoch_ratchet() for _ in range(1000): msg = randombytes(612) self.assertEqual( self.t1.tunnel_recv(self.t2.tunnel_send(msg)), msg, ) def test_packet_replay(self) -> None: """Tests that we cannot decrypt the same message twice""" msg = randombytes(128) data = self.t2.tunnel_send(msg) self.assertEqual(self.t1.tunnel_recv(data), msg) self.assertEqual(self.t1.tunnel_recv(data), b"") def test_out_of_order_packets(self) -> None: """Tests that packets received out of order are decryptable""" msg = randombytes(256) pkts = [] for _ in range(MAX_EPOCHS): pkts.append(self.t1.tunnel_send(msg)) self.t1.send_epoch_ratchet() while pkts: data = pkts.pop() self.assertEqual(self.t2.tunnel_recv(data), msg) def test_tunnel_from_cookie_data(self) -> None: """Tests that a tunnel recreated from previous state (recovered from a cookie) functions as expected. """ tid = self.t1.get_tid() epoch = self.t1._send_chain.get_epoch_no() ts = int(monotonic()) send_key = self.t1._send_chain._chain.get_next_epoch_key() send_key = bytes([send_key[i] for i in range(len(send_key))]) recv_key = self.t1._recv_chain._chains[epoch].get_next_epoch_key() recv_key = bytes([recv_key[i] for i in range(len(recv_key))]) msg = randombytes(1000) self.assertEqual( self.t1.tunnel_recv(self.t2.tunnel_send(msg)), msg, "t1 and t2 not working", ) tun = TunnelSession.from_cookie_data(tid, epoch, send_key, recv_key) self.t2.recv_epoch_ratchet() self.t2.send_epoch_ratchet() for _ in range(1000): msg = randombytes(128) data = self.t2.tunnel_send(msg) self.assertEqual(tun.tunnel_recv(data), msg) tun.close() class TestState(TestCase): def setUp(self) -> None: self.tid = randombytes(32) self.t1_send_root = randombytes(32) self.t1_recv_root = randombytes(32) self.t2_send_root = bytes( [self.t1_recv_root[i] for i in range(len(self.t1_recv_root))] ) self.t2_recv_root = bytes( [self.t1_send_root[i] for i in range(len(self.t1_send_root))] ) self.tme = TunnelSession( self.tid, self.t1_send_root, self.t1_recv_root ) self.tpeer = TunnelSession( self.tid, self.t2_send_root, self.t2_recv_root ) def tearDown(self) -> None: self.tme.close() self.tpeer.close() def test_forward_sync(self) -> None: """Tests that when we receive a message from a later epoch than our current send epoch, we ratchet forward to that epoch. """ msg = b"hello" start = int(monotonic()) # Sanity check that things are set up correctly expire_0 = self.tme._send_chain._chain._expire self.assertEqual(expire_0, start + EPOCH_DURATION_SECONDS) self.assertEqual(self.tpeer._send_chain._epoch, 0) self.assertEqual( self.tme.tunnel_recv(self.tpeer.tunnel_send(msg)), msg ) self.assertEqual(len(self.tme._recv_chain._chains), 1) # sanity check that epoch ratcheting actually does something sleep(1) old_expire = self.tpeer._send_chain._chain._expire self.tpeer.send_epoch_ratchet() new_expire = self.tpeer._send_chain._chain._expire self.assertNotEqual(old_expire, new_expire) self.assertEqual(self.tpeer._send_chain._chain._epoch, 1) self.assertEqual( self.tpeer._send_chain._chain._expire, start + 1 + EPOCH_DURATION_SECONDS, ) # Send/recv msg from different epoch ct = self.tpeer.tunnel_send(msg) self.assertEqual(self.tme.tunnel_recv(ct), msg) # Check that local state is correct self.assertEqual(self.tme._send_chain._epoch, 1) self.assertEqual(len(self.tme._recv_chain._chains), 2) # Send a response in epoch 1 msg = b"new message" self.assertEqual( self.tpeer.tunnel_recv(self.tme.tunnel_send(msg)), msg ) self.assertEqual(self.tpeer._send_chain._epoch, 1) pqconnect-1.2.1/test/test_util.py0000644000000000000000000000267614733452565015601 0ustar rootrootfrom unittest import TestCase from pqconnect.common import util class TestUtil(TestCase): def test_base32_encoding(self) -> None: """taken from https://datatracker.ietf.org/doc/html/draft-dempsky-dnscurve-01#section-3.1 """ vectors = [ (b"\x64\x88", "4321"), (b"", ""), (b"\x88", "84"), (b"\x9f\x0b", "zw20"), (b"\x17\xa3\xd4", "rs89f"), (b"\x2a\xa9\x13\x7e", "b9b71z1"), (b"\x7e\x69\xa3\xef\xac", "ycu6urmp"), (b"\xe5\x3b\x60\xe8\x15\x62", "5zg06nr223"), (b"\x72\x3c\xef\x3a\x43\x2c\x8f", "l3hygxd8dt31"), (b"\x17\xf7\x35\x09\x41\xe4\xdc\x01", "rsxcm44847r30"), ] for a, b in vectors: self.assertEqual(util.base32_encode(a), b) def test_base32_decoding(self) -> None: """Tests that decoding also works""" vectors = [ (b"\x64\x88", "4321"), (b"", ""), (b"\x88", "84"), (b"\x9f\x0b", "zw20"), (b"\x17\xa3\xd4", "rs89f"), (b"\x2a\xa9\x13\x7e", "b9b71z1"), (b"\x7e\x69\xa3\xef\xac", "ycu6urmp"), (b"\xe5\x3b\x60\xe8\x15\x62", "5zg06nr223"), (b"\x72\x3c\xef\x3a\x43\x2c\x8f", "l3hygxd8dt31"), (b"\x17\xf7\x35\x09\x41\xe4\xdc\x01", "rsxcm44847r30"), ] for a, b in vectors: self.assertEqual(util.base32_decode(b), a)