Prototype I: ZKP and chaos

The first prototype introduced here combines the work of Schnorr’s Non-Interactive Zero Knowledge Proof (NIZKP) and the Chaos-based Keyed Hash Function, from 3.1 to 3.2 in the preceding subsection. Prototype I will display a large amount of the general structure ahead of its successors.

Setup

The setup of a client and server is the only out-of-band process, used to derive the secrets and configurations required for port knocking operations. The subroutine achieving this produces a profile JSON file each for client and server, housing cryptograhic parameters and configuration settings. The profile contents include:

Schnorr NIZKP parameters (refer to Sect. 3.1.2) Group parameters p , q , g are generated using a call to the openssl library. Specifically this uses the dsaparam module, to generate DSA parameters, which are applicable for the NIZKP as outlined in [26]. DSA key-length is set to 2048-bit. Private key a is randomly selected from the range \([0,q-1]\) and is used to derive public key \(A \equiv g^{a}\). Note: the only difference between the client and server profiles generated is the absence of a in the server profile, as the ZKP proves knowledge of a . 8-bit command ID - for future development to support multiple commands under a single profile. In the original protocol, these were refered to as UserID , and OtherInfo .

Private hash key: a float number in the range \((-1,1)\), with 256-bit precision, for an associated 256-bit keyspace. Implemented using the mpmath 3rd-party Python library.

Server port number: randomly generated during setup, and fixed throughout all exchanges.

Command: user specified shell command to run upon successful client authentication.

On a modern laptop, generation of the profiles was near instantaneous, and each was sized at 3KB. The profiles should be securely transferred and housed on the client and server machines.

Chaos-based hash function

The hash digest length was increased to 256-bit, as was required by RFC 8235 [26] for compatability with the NIZKP: “The bit length of the hash output should be at least equal to that of the order q of the considered subgroup.” This level of precision necessitated that the mpmath library be used, above the standard float data types that ship with Python. The horsepower required to manage calculations with these floats, with up to \(\log (2^{256})/\mathrm{log}(10) \approx 78\) decimal places, had a dramatic affect on the hashing speeds, with the hash taking over 20 s to calculate the required digest for the NIZKP protocol. Issues with chaotic map computations of floating-point numbers were noted by [32], though were not expected to be this severe. A large factor in the sluggishness of the hash function was the number of iterations to perform, or in terms of the chaotic map, the time parameter t. From the authors’ paper, little guidance was set for how to select this parameter, \(t=10,000\) was one such baseline used for testing, however this proved unachievable in practical time. A further factor in this speed is undoubtedly increasing the hash’s bit length. Lastly, as the hash was implemented in Python, a high-level language, time savings could further be gained by implementing the final version of the port knocking application in a language closer to the client hardware.

Client actions

In Schnorr’s NIZKP, a hash is taken of the public key A, the random walk \(V \equiv g^{v}\), where v is randomly generated by the client, and other, non-essential parameters. The resulting digest c, accompanied by \(r \equiv v-ac\), form the proof of knowledge, or knock in this case, as the pair of concatenated values c||r, that are then transmitted to the port knocking server in the payload of a UDP datagram.

UDP was chosen as the transport protocol for sending the values, as TCP connection-based overhead and interactivity were not deemed necessary nor applicable, respectively, to minimalist design goals. ICMP traffic could be an alternative, though nothing was found in the literature to support this approach, whereas [12, 47, 48] provide justifications for choosing UDP. Python’s native socket library was used to send the knock packet, where the server IP and destination port values are both taken from the client profile, the former being user input from stdin, and the latter being randomly generated.

Server actions

The server parses traffic using scapy, a 3rd-party Python library that provides an interface to lower-level libpcap functionality. The open-source libpcap library for network traffic capture operates on Linux systems, allowing packets to be inspected before firewall rules [7], and is “amongst the most widely used [APIs] for network packet capture”. Scapy is used to continually sniff the server’s network interface. Collected traffic is then subjected to conditional rulesets designed to filter out traffic not meeting ZKP criteria:

1. Traffic must be destined for the server port, as setup in the client profile. 2. Traffic must be UDP, with an integer payload. 3. The payload is of length equal to the hash digest length, plus an integer \(r \mod q\), padded with zeroes to fix this length.

If a packet is sniffed meeting these criteria, then the Schnorr NIZKP Protocol (see 3.1.2) outlines the mathematical checks performed to validate the proof, computationally, this involves:

1. Calculate c, v, by splitting the packet payload into their appropriate lengths. 2. Calculate \(V \equiv g^r A^c\) using values from Step 1, and the client profile. 3. Check whether the received c is equal to: \(\text {H} \left( V \Vert A \Vert \text {CommandID} \right) \)

If the proof is successful, the client is authenticated, and their requested shell command is executed. Though not implemented, the protocol allows for accompanying the proof with further data, this could be used to facilitate multiple command options for a single client, and multiple users.

Discussion

Prototype I uses third-party libraries only for floating-point computations, and packet sniffing from the wire. Theoretically, a powerful pocket calculator could generate the proof required to authenticate, and it could be sent via any number of online services for testing network connectivity. It could be argued that reducing dependencies such as 3rd-party libraries use will reduce threat vectors from exploitation of those libraries. The prototype is further well suited for application on devices with limited ability to maintain updated libraries. Replay protection is a corollary of mixing randomly chosen v, and resulting V, in some form, into c and r, ensuring each time a knock is sent, each element in the proof is derived from a nonce.

Returning to the design goals, the aim of having the server precompute acceptable knock values is entirely missed. Instead, this prototype requires the server to check a potential client proof, requiring two exponentiation operations in the group setting [26], and a hash function execution. This opens a serious attack vector: by sending data to a suspected port knocking service port, an attacker can force the server to perform computations, potentially blocking out valid client knocks. As per [49], “processing required to verify the [knock] should be minimal and not introduce a [DoS] vector.”. Filtering options are limited for preventing such attacks, as knock values are pseudo-random, possessing no identifiable characteristics other than length, contradicting goals from [49]: “unauthorized packets should be rejected as early as possible to reduce attack surface and decrease server side processing.”

To combat this, a traffic rate limiter could be enforced (the scapy sniffer offers such a feature) though this could in turn enable an attacker to purposely trigger the rate limiter to block out legitimate clients. Alternatively, the server’s sniffer could establish a sort of reputational filter, ignoring the traffic from IPs that have sent invalid knocks, though this could be circumvented if the attacker spoofed their IP. Hopefully these scenarios illustrate why both the only precomputation and only key-based authentication were included as design goals. Suffice to say, this prototype does not fare well against denial of service attacks. DoS mitigation could be supplied by network and host monitoring solutions, such as IDPS, though this should not be required of a port knocking implementation.

Prototype II: chaos and random beacons

The second prototype explored in this subsection forgoes zero knowledge proofs, and instead adopts a random beacon service, as described in 3.3. The chaos-based hash functionality is further retained, though this time it is used to parse the random beacon. The client and server profiles are setup as previously, though without the parameters for Schnorr’s NIZKP. Largely, the code is replicated from the previous prototype, with modifications outlined in the following.

Blockchain-based random beacon

The Bitcoin random beacon protocol, as shown in Sect. 3.3.2, pulls information from a website providing updates on Bitcoin’s blockchain. The Python code for implementing this feature is taken from [50], with small changes, including a change of website to the blockchain API provided by [51], which for this purpose requires only a single HTTPS GET request using the Python native request library, to a URL of the website’s blockchain API. This API replies, offering information on the latest published block in JSON format. From there, the block header, and block header hash, are extracted and combined through a pairwise OR. The resulting string is then passed through the chaos-based keyed hash function, resulting in a 256-bit beacon value. Of note, if the hash function used were not keyed, then the attacker would be able to recreate the beacon output. Given that the key used in the hash function is only available to client and server, as per Protocol I’s setup, this means the beacon values are shared, random and private. The keyed hash function could be used to combine multiple random beacons, for greater security (see Sect. 3.3.1, though this is beyond the scope of the demonstration here.

After a new beacon has just been received, the beacon service sleeps for a fixed number of minutes, which can be changed to preference in accordance with block production speeds varying roughly around the 1 per 10 minutes mark [38]. Following this wait, the beacon will check more frequently, until the data pulled from the API differs, signalling a recalculation of the knock to expect from a client.

Client operations

For this prototype, the UDP knock payload is defined as \(H_{k}(\text {beacon}||\text {command})\), where k and H are the key and chaos-based hash implemented in the previous prototype. As discussed in the port knocking mechanisms literature review, hashes are one-way functions that can provide a proof of identity: the attacker can only generate this payload if they are privy to the secret key, which is securely shared in out of band setup. Replay protection in this instance is provided by the freshness derived from the beacon. After processing, the beacon’s values are pseudo-random, so the only instance in which the hash function receives two identical messages would be where the blockchain API reports an identical block and block hash. Therefore, the only instance in which a knock-collision occurs would result from either this scenario, or a weakness in the hash function. The security level resulting from a 256-bit keyspace for the chaos-based hash function ensures this is unlikely. Once constructed, the UDP payload is sent out as previously.

Server operations

The port knocking server’s operations include periodically harvesting random beacon data, converting this data into the knock it expects to see from the client, and monitoring the network to detect whether this knock has been received. These responsibilities require a degree of concurrency, for example the server must retain its listening abilities whilst at the same time calculating what the proceeding knock should look like, which uses the overwhelmingly slow chaos-based hash function. To handle these tasks in parallel, the native Python multiprocessing library is used to setup individual processes for traffic monitoring, beacon harvesting, and authorised command handling. Special variables handled by a multiprocessing Manager are shared between the processes, for instance to signal that the client has successfully authenticated.

Discussion

Once a beacon has been harvested and processed, the server is left with a string value to listen for on the network, which if found, signals authentication, and subsequently the user’s command is authorised. To enable multiple commands, a new \(H_{k}(\text {beacon}||\text {command})\) is calculated for each, per beacon, and accordingly listened for. In comparison with the first protocol, and in the context of the design goals, this variant is vastly more simple, and has managed to eschew the denial of service attack vectors the latter was vulnerable to. Where previously the server performed exponential calculations and hashing operations to verify the authenticating data, this prototype need only check whether strings are equal. By removing the Schnorr framework, only a single private key is required (for the keyed hash), and the openssl library can be forgone. The introduction of a trusted third party (the blockchain API), is an obvious security detractor. Further examination of this aspect will follow later, in the evaluation section.

Prototype III: crucible

The final prototype introduced by this paper differs largely from the previous iterations. As a consequence of research leading up to this section, Prototype III, henceforth named Crucible (a namesake derived from its blending of ideas, much like elements in a furnace) draws its motivations from zero knowledge proofs, and chaos-based cryptography, but instead replaces both previously implemented components with dedicated off-the-shelf, cryptographic alternatives, that are already established in practice.

For authentication purposes, the previous review of zero knowledge proofs aimed to lead development towards a practical proof of identity scheme which could be used to prove knowledge of a secret key. Crucible pursues this line of thinking, but instead uses a password-based key derivation function (PBKDF) to prove knowledge of a password. In doing so, the server does not possess the password itself, only its hash. In both previous prototypes, the run-time of the chaos-based keyed hash severely dampened results, instead, in Crucible, it will be replaced with a modern keyed hash alternative.

Password-based key derivation

A key derivation function converts a secret value (such as a master key, passphrase, or password) into a secure cryptographic key [34]. Password-based key derivation functions (PBKDF) are the family of such solutions aimed at converting potentially weak user supplied passwords into a keys that are specifically designed to be resistant to cracking attempts, making them well suited for storing user credentials on a server, whilst limiting the exposure to users if that server were to be compromised [52].

As from the discussion on what comprises a secure cryptographic hash in Sect. 3.2.2, a PBKDF requires all the qualities of preimage, second-preimage, and collision resistance. In addition to this, a PBKDF needs resistance to lookup table attacks (e.g., rainbow tables), CPU-optimised cracking, hardware-optimised cracking (e.g., GPUs, FPGAs and ASICs), amongst other criteria, as established by the 2015 Password Hashing Competition [52]. Argon2 was the winner of this competition, and is selected for purpose in Crucible. Further details on Argon2 can be found in the IETF draft [53] and design paper [54].

The prototype uses Python’s native passlib library, updated with installation of the Argon2 package. This specifically uses the Argon2i variation, recommended for password hashing purposes, as opposed to other KDF operations [53]. The hash parameters chosen for this prototype set Argon2 to use 20 rounds, 256 MB ram, and 2 threads of parallelism, producing a hash in under 2 s on a modern laptop. Salt is set to a fixed value (explained later in Sect. 5.1.3), and with the chosen parameters is stored alongside the digest, in a single hash string. These settings should be chosen in implementation to maximise computation efforts under the used hardware environment.

The setup process is similar to the preceding prototypes, where instead Argon2 replaces key generation procedures: the user is asked to input their chosen password (which they are to memorise), the hash of which is then stored only on the port knocking server. In addition to memorising the password, with Crucible the user needs to know only the IP address on which to knock, and the command name to execute. In this manner, Crucible is stateless, whereby a user can download the client application, and perform port knocking, without needing access to secret keys or other parameters, i.e., following installation of a profile on the server, a client profile (as per previous prototypes) is not required.

Keyed hash

With the client able to derive a key from their chosen password, Crucible follows Prototype II in using a keyed hash to digest a random beacon. In preference of the chaos-based keyed hash already explored previously, an established hash function is taken from the literature.

BLAKE2 was chosen to perform the task of hashing operations in Crucible, being amongst the fastest secure hash functions available, and the most-popular non-NIST standard hash [34]. Unlike alternatives Siphash, and SHA3, BLAKE2 is resistant to side-channel attacks, where an attacker has access to RAM and registers [34]. Further, unlike SHA3, BLAKE2 has native support for keyed mode hashing, without additional construction. More information on BLAKE2 can be found in [55].

The particular variant BLAKE2b, was chosen for 64-bit optimisation in the test environment. The pyblake2 3rd-party Python library [56] was used for the Python 2.7 implementation (referenced on the authors’ website [57]), however the native hashlib supports BLAKE2 for later versions.

Setup

Ahead of port knocking operations, the client must generate a profile to leave with the server, used by the server to know which knocks to listen for. The generation process firstly prompts the user for a password, and the user is then directed to submit a number of commands for the server to execute on successful authentication. Each command must be named, and along with the password and IP of the server, each command name must be memorised. Following this, per the previous prototypes, the client profile is serialised in JSON and saved in a text file, to remain with the server.

Client operations

To execute a command on the port knocking server, a remote user runs the client Python script, and the following operations are performed:

1. The client supplied password is run through Argon2 to generate the associated key: \(\text {key}=\text {Argon2b}(\text {password})\). 2. The client script pulls down and calculates the most recent random beacon from the blockchain API. 3. The beacon and client supplied command are concatenated, and hashed with BLAKE2, using a keyed mode of operation: \(\text {knock}=\text {BLAKE2b}_{\text {key}}(\text {beacon}||\text {command})\) 4. The server’s port number listening to the knocks is derived similarly to the key, though instead the command is replaced with “0”. The first two bytes of the resulting hash are then converted into an integer port number, this combined with the IP provide the destination to which the knock is sent.

The key, beacon, and knock at i are calculated as:

$$\begin{aligned} \text {key}&= \text {Argon2i}\left( \text {password}\right) , \\ \text {beacon}&= \text {BLAKE2b}_{\text {key}}\left( \text {blockHeader}\right. \\&\quad \left. +\, \text {blockHeaderHash}\right) , \text {knock}_{i} \\&= \text {BLAKE2b}_{\text {key}}\left( \text {beacon} || \text {command}_{i}\right) \end{aligned}$$

where i represents the chosen command for the particular knock session (the server will listen for all possible values of i).

Fig. 2 Crucible setup: generating a client profile Full size image

Server operations

The structure for the server operations is largely similar to Prototype II: there are a number of interrelated tasks the server needs to perform concurrently, managed through Python’s multiprocessing library:

The server periodically checks the blockchain API for new values, and stores harvested beacons in a multiprocessing Manager dictionary, to enable access by other processes. Once a new beacon is harvested, a boolean in the dictionary is flipped, signalling that the traffic sniffing process needs to update the valid knocks it listens to.

Whenever a new beacon is found, for each command in the client profile a new valid knock string must be calculated (per Client Operation 3). Once generated, each knock string is sent to the listening process, using scapy. This listening process acts on the port number generated per client Operation.

The listening process, checks each received UDP packet, of appropriate length, on the correct port, against the calculated valid knock values. If a match is found, the relevant command is sought and executed.

Fig. 3 Normal client and server operations of Crucible Full size image

Fig. 4 Wireshark traffic capture of the client knocking process. Packets 10–12 show the client’s DNS request for the Blockchain API’s IP. Packets 12–24, and 28–29 show the random beacon retrieval over HTTPS. Packet 26 is the client knock. Packet 27 is a closed-port ICMP reply, per RFC 792 [58] Full size image

Discussion

The random port number derived from the beacon serves two purposes: firstly, it may make the job of an attacker listening on the wire slightly more difficult, as there is one less defining traffic characteristic to filter by; secondly, it improves usability, as the user no longer needs to memorise the port number to knock against. This change from Protocol II is made possible by the enormous difference in hash run-times.