Repository: coreweave/tensorizer (language: python)

Issue:
feat(crypto): Initial pass at crypto implementation.
Tensor encryption and decryption.

Some design decisions and notes:
- We only encrypt the actual tensor payload itself. This helps us avoid a format change. There's a case to encrypting the metadata header as well, but that requires a format change so I'm punting that.
- We leverage the existing hash fields to contain the salt, nonce, and block_size for each tensor.
- Encryption is easy. Just provide passphrase= argument to the Deserializer and Serializer.
- We follow good practices, such as: random nonce's, ensuring that there's a new nonce each and every time, salting the passphrase with a random salt.
- By design, we don't encrypt the entire tensor payload in one go -- instead we use a streaming block cipher, xsalsa20 with poly HMAC -- this allows us to decrypt concurrently with reads rather than waiting for the entire thing.


Discussion / hints:

Fix patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 130997b9..f83af425 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,30 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
 and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
 
+## [Unreleased]
+
+### Added
+
+- Tensor encryption
+  - Refer to [docs/encryption.md](/docs/encryption.md) for details 
+  - Encrypts all tensor weights in a file with minimal overhead
+  - Doesn't encrypt tensor metadata, such as:
+    - Tensor name
+    - Tensor `dtype`
+    - Tensor shape & size
+  - Requires an up-to-date version of `libsodium`
+    - Use `apt-get install libsodium23` on Ubuntu or Debian
+    - On other platforms, follow the
+      [installation instructions from the libsodium documentation](https://doc.libsodium.org/installation)
+    - Takes up less than 500 KiB once installed
+  - Uses a parallelized version of XSalsa20-Poly1305 as its encryption algorithm
+    - Splits each tensor's weights into &leq; 2 MiB chunks, encrypted separately
+  - Example usage: see [examples/encryption.py](examples/encryption.py)
+  - Example CLI tool to add or remove encryption from pre-serialized models:
+    [examples/encrypt_existing.py](examples/encrypt_existing.py)
+- Fix cases where the `pynvml` library was available on a node with no NVML
+  devices. This allows CPU-only deployments to work with `pynvml` in the image.
+
 ## [2.6.0] - 2023-10-30
 
 ### Added
@@ -220,6 +244,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
   - `get_gpu_name`
   - `no_init_or_tensor`
 
+[Unreleased]: https://github.com/coreweave/tensorizer/compare/v2.6.0...HEAD
 [2.6.0]: https://github.com/coreweave/tensorizer/compare/v2.5.1...v2.6.0
 [2.5.1]: https://github.com/coreweave/tensorizer/compare/v2.5.0...v2.5.1
 [2.5.0]: https://github.com/coreweave/tensorizer/compare/v2.4.0...v2.5.0
@@ -232,4 +257,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
 [2.0.0]: https://github.com/coreweave/tensorizer/compare/v1.1.0...v2.0.0
 [1.1.0]: https://github.com/coreweave/tensorizer/compare/v1.0.1...v1.1.0
 [1.0.1]: https://github.com/coreweave/tensorizer/compare/v1.0.0...v1.0.1
-[1.0.0]: https://github.com/coreweave/tensorizer/releases/tag/v1.0.0
\ No newline at end of file
+[1.0.0]: https://github.com/coreweave/tensorizer/releases/tag/v1.0.0
diff --git a/README.md b/README.md
index f9e42e66..7516b282 100644
--- a/README.md
+++ b/README.md
@@ -212,6 +212,71 @@ where `df_main()` serializes models from
 and `hf_main()` serializes
 [HuggingFace Transformers](https://github.com/huggingface/transformers) models.
 
+## Tensor Weight Encryption
+
+`tensorizer` supports fast tensor weight encryption and decryption during
+serialization and deserialization, respectively.
+
+Be aware that metadata (tensor names, dtypes, shapes, etc.) are not encrypted,
+only the weights themselves.
+
+> [!NOTE]
+> 
+> Refer to [docs/encryption.md](/docs/encryption.md) for details, instructions,
+> and warnings on using `tensorizer` encryption correctly and safely.
+
+To use `tensorizer` encryption, a recent version of `libsodium` must be
+installed. Install `libsodium` with `apt-get install libsodium23`
+on Ubuntu or Debian, or follow
+[the instructions in `libsodium`'s documentation](https://doc.libsodium.org/installation)
+for other platforms.
+
+### Quick Encryption Example
+
+The following outline demonstrates how to encrypt and decrypt a tensorized model
+with a randomly-generated encryption key:
+
+```py
+from tensorizer import (
+    EncryptionParams, DecryptionParams, TensorDeserializer, TensorSerializer
+)
+
+# Serialize and encrypt a model:
+
+encryption_params = EncryptionParams.random()
+
+serializer = TensorSerializer("model.tensors", encryption=encryption_params)
+serializer.write_module(...)  # or write_state_dict(), etc.
+serializer.close()
+
+# Save the randomly-generated encryption key somewhere
+with open("tensor.key", "wb") as key_file:
+    key_file.write(encryption_params.key)
+
+
+# Then decrypt it again:
+
+# Load the randomly-generated key from where it was saved
+with open("tensor.key", "rb") as key_file:
+    key: bytes = key_file.read()
+ 
+decryption_params = DecryptionParams.from_key(key)
+
+deserializer = TensorDeserializer("model.tensors", encryption=decryption_params)
+deserializer.load_into_module(...)
+deserializer.close()
+```
+
+For more detail, refer to [docs/encryption.md](/docs/encryption.md).
+A complete example is also available as
+[examples/encryption.py](examples/encryption.py).
+The `EncryptionParams` and `DecryptionParams` class docstrings additionally
+contain some usage information for quick reference from an IDE.
+
+An example command line tool to add or remove encryption from existing
+serialized models is also available as
+[examples/encryption.py](examples/encrypt_existing.py).
+
 ## Benchmarks
 
 You can run your own benchmarks on CoreWeave or your own Kubernetes cluster
diff --git a/docs/encryption.md b/docs/encryption.md
new file mode 100644
index 00000000..411d898a
--- /dev/null
+++ b/docs/encryption.md
@@ -0,0 +1,328 @@
+# Tensor Encryption
+
+`tensorizer` supports fast tensor weight encryption and decryption during
+serialization and deserialization, respectively.
+
+> [!NOTE]
+> 
+> To use `tensorizer` encryption, a recent version of `libsodium` must be
+> installed. Install `libsodium` with `apt-get install libsodium23`
+> on Ubuntu or Debian, or follow
+> [the instructions in `libsodium`'s documentation](https://doc.libsodium.org/installation)
+> for other platforms.
+
+## Encryption Algorithm
+
+Tensor encryption splits weights into up-to-2 MiB chunks encrypted independently
+with XSalsa20-Poly1305 symmetric authenticated encryption,
+stored separately from their MACs.
+These chunks can be encrypted or decrypted and authenticated independently of
+one another in the style of a block cipher,
+which allows decryption parallelized with streaming.
+
+All encryption and decryption is done in-place, as encrypted payloads are equal
+in length to their plaintexts in this scheme (since the MACs are stored
+separately).
+This allows for high speed processing, since memory allocations can be avoided.
+
+## Scope / Security
+
+Only tensor weights are encrypted, using 256-bit keys (see
+[Choosing a Key Derivation Algorithm](#choosing-a-key-derivation-algorithm)),
+and weight chunks are independently authenticated.
+This is meant to provide security against tensor weights from being read by a
+third party, plus a small amount of authentication to confirm that,
+for example, a matching passphrase was used for encryption and decryption;
+security beyond that is beyond the scope of `tensorizer`'s encryption as
+currently available.
+
+> [!WARNING]
+> 
+> This does not include encryption for anything except for tensor weights.
+> Metadata such as a tensor's name, dtype, shape, size, and non-keyed hashes
+> are neither encrypted nor authenticated.
+
+> [!WARNING]
+> 
+> This level of encryption does not provide message authentication for metadata
+> and does not protect against reordering or truncation of chunks.
+
+> [!NOTE]
+> 
+> Unencrypted/unauthenticated tensor data is rejected during deserialization
+> if decryption is requested, and vice versa.
+
+## Usage
+
+A full usage example is given in
+[`examples/encryption.py`](/examples/encryption.py).
+
+The class docstrings of `EncryptionParams` and `DecryptionParams` include
+usage outlines like below as well as additional usage information.
+Most IDEs support automatically displaying this information while coding.
+
+### Encrypting and Decrypting Existing Models with a CLI Tool
+
+Existing models that have already been serialized by tensorizer can have
+encryption added to them or removed from them using the example
+[`examples/encrypt_existing.py`](/examples/encrypt_existing.py)
+command line utility. Download the script, then run
+`python encrypt_existing.py -h` to see usage.
+
+The source code in [`examples/encrypt_existing.py`](/examples/encrypt_existing.py)
+also serves as usage examples for the various encryption methods.
+
+Examples:
+
+```bash
+# Global help and subcommand help
+python encrypt_existing.py --help
+python encrypt_existing.py add pwhash --help
+
+# Encrypt using a random binary key (outputs generated key to --keyfile)
+python encrypt_existing.py add random --keyfile encrypted.tensors.key \
+  --infile original.tensors --outfile encrypted.tensors
+
+# Encrypt using a pre-existing binary key (reads key from --keyfile)
+python encrypt_existing.py add exact --keyfile encrypted.tensors.key \
+  --infile original.tensors --outfile encrypted.tensors
+
+# Encrypt using Argon2id key derivation (reads string to turn into a key from --keyfile)
+python encrypt_existing.py add pwhash --keyfile encrypted.tensors.key \
+  --opslimit MODERATE --memlimit MODERATE \
+  --infile original.tensors --outfile encrypted.tensors
+
+# Decrypt using a binary key (reads key from --keyfile)
+python encrypt_existing.py remove exact --keyfile encrypted.tensors.key \
+  --infile encrypted.tensors --outfile decrypted.tensors
+
+# Decrypt using Argon2id key derivation (reads string to turn into a key from --keyfile)
+python encrypt_existing.py remove pwhash --keyfile encrypted.tensors.key \
+  --infile encrypted.tensors --outfile decrypted.tensors
+```
+
+### Using `EncryptionParams.random()`
+
+This is the preferred method of tensor encryption and decryption.
+Use this unless you have a good reason to do otherwise.
+
+```py
+from tensorizer import (
+    EncryptionParams, DecryptionParams, TensorDeserializer, TensorSerializer
+)
+
+# Serialize and encrypt a model:
+encryption_params = EncryptionParams.random()
+
+serializer = TensorSerializer("model.tensors", encryption=encryption_params)
+serializer.write_module(...)  # or write_state_dict(), etc.
+serializer.close()
+
+# Save the randomly-generated encryption key somewhere
+with open("tensor.key", "wb") as key_file:
+    key_file.write(encryption_params.key)
+
+
+# Then decrypt it again:
+
+# Load the randomly-generated key from where it was saved
+with open("tensor.key", "rb") as key_file:
+    key: bytes = key_file.read()
+ 
+decryption_params = DecryptionParams.from_key(key)
+
+deserializer = TensorDeserializer("model.tensors", encryption=decryption_params)
+deserializer.load_into_module(...)
+deserializer.close()
+```
+
+### Using `EncryptionParams.from_string()` with an environment variable
+
+If an encryption key must be provided as a pre-existing string,
+this method of encryption will allow the use of that string,
+and does not require saving a key generated at the time of encryption.
+
+> [!WARNING]
+>
+> Make sure a secure input string is used to create a key.
+> `EncryptionParams.from_string()` accepts parameters to tune its algorithm
+> to make searching the input string via brute-force checking less viable,
+> but nothing can protect against weak enough input strings,
+> like your birthdate, or a common password.
+
+```py
+from tensorizer import (
+    EncryptionParams, DecryptionParams, TensorDeserializer, TensorSerializer
+)
+
+source: str = os.getenv("SUPER_SECRET_STRONG_PASSWORD")
+
+# Serialize and encrypt a model:
+encryption_params = EncryptionParams.from_string(source)
+serializer = TensorSerializer("model.tensors", encryption=encryption_params)
+serializer.write_module(...)  # or write_state_dict(), etc.
+serializer.close()
+
+# Then decrypt it again:
+decryption_params = DecryptionParams.from_string(source)
+deserializer = TensorDeserializer("model.tensors", encryption=decryption_params)
+deserializer.load_into_module(...)
+deserializer.close()
+```
+
+### Choosing a Key Derivation Algorithm
+
+The classes `EncryptionParams` and `DecryptionParams` allow a choice of
+key derivation method. Two methods are implemented:
+
+1. Random key generation
+    1. Chosen by constructing an `EncryptionParams` object through calling
+       `EncryptionParams.random()`
+    2. Not technically key derivation
+    3. Uses a completely random 32-byte sequence with no associated passphrase
+    4. Highly secure against being guessed
+    5. You must save the randomly generated key
+2. [Argon2id](https://datatracker.ietf.org/doc/html/rfc9106) key derivation
+    1. Chosen by constructing an `EncryptionParams` object through calling
+       `EncryptionParams.from_string(source)`
+    2. Transmutes an arbitrary-length `str` or `bytes` source string into a
+       binary encryption key
+    3. Implements adjustable security against brute-force cracking
+       via its `opslimit` and `memlimit` parameters
+    4. Internally uses
+       [`libsodium`'s `pwhash` function with the algorithm `crypto_pwhash_ALG_ARGON2ID13`](https://libsodium.gitbook.io/doc/password_hashing/default_phf#key-derivation)
+
+An `EncryptionParams` object is passed to a `TensorSerializer` using its
+`encryption=...` keyword-only parameter during initialization.
+
+#### `EncryptionParams.from_string()` details (Argon2id)
+
+`EncryptionParams.from_string()` uses the Argon2 (Argon2id, RFC 9106)
+password hashing algorithm to create a key from an input string.
+
+The key has resistance against brute-force attacks that attempt
+to guess the input string, achieved by making each attempt
+expensive to compute, both in CPU time and RAM usage.
+
+The computational difficulty can be increased or decreased
+via the `opslimit` and `memlimit` parameters.
+Higher computational difficulty gives more security
+for weak input strings, but may impact performance.
+The default setting is a "moderate" profile taken from `libsodium`.
+
+Presets (as well as minimum values) are available through the
+`EncryptionParams.OpsLimit` and `EncryptionParams.MemLimit` enums.
+
+Rough estimates of performance impact (on a 3.20 GHz processor):
+
+```py
+from tensorizer import EncryptionParams
+
+OpsLimit = EncryptionParams.OpsLimit
+MemLimit = EncryptionParams.MemLimit
+s = "X" * 40
+
+EncryptionParams.from_string(  # Takes about 0.05 ms, 8 KiB RAM
+    s, opslimit=OpsLimit.MIN, memlimit=MemLimit.MIN
+)
+EncryptionParams.from_string(  # Takes about 90 ms, 64 MiB RAM
+    s, opslimit=OpsLimit.INTERACTIVE, memlimit=MemLimit.INTERACTIVE
+)
+EncryptionParams.from_string(  # Takes about 500 ms, 256 MiB RAM
+    s, opslimit=OpsLimit.MODERATE, memlimit=MemLimit.MODERATE
+    # Default: equivalent to opslimit=None, memlimit=None
+)
+EncryptionParams.from_string(  # Takes about 3.0 seconds, 1 GiB RAM
+    s, opslimit=OpsLimit.SENSITIVE, memlimit=MemLimit.SENSITIVE
+)
+```
+
+Timing may be different on different hardware.
+These do not reflect the exact times an attacker may require for each guess.
+
+##### Performance tuning
+
+If possible, use `EncryptionParams.random()` instead of
+`EncryptionParams.from_string()`, and save the generated key
+to use for decryption.
+
+If that is not possible, save the binary key generated during
+`EncryptionParams.from_string()` (from the `.key` attribute),
+and use that key for decryption (via `DecryptionParams.from_key()`)
+to remove the cost of re-computing the key at deserialization time.
+
+If that is not possible, use a strong input string.
+For input strings that are already very strong and high-entropy,
+where brute-force attacks on the input string are no more likely
+to succeed than brute-force attacks on a 256-bit key itself,
+(e.g. very long, randomly generated strings),
+`opslimit` and `memlimit` may be tuned down to minimize
+their performance impact.
+
+If that is not possible, test different values of `opslimit`
+and `memlimit` to determine an acceptable tradeoff between
+performance and security for your use case.
+
+See also:
+- [`libsodium` documentation for `pwhash`](https://libsodium.gitbook.io/doc/password_hashing/default_phf#key-derivation),
+  the Argon2id implementation used in `EncryptionParams.from_string()`
+- [RFC 9106](https://datatracker.ietf.org/doc/html/rfc9106)
+  for details on Argon2 and Argon2id
+
+#### Using the right key derivation algorithm for decryption
+
+Specifying whether to decrypt with a passphrase or key is done with
+a `DecryptionParams` object.
+A `DecryptionParams` object is passed to a `TensorDeserializer` using its
+`encryption=...` keyword-only parameter during initialization.
+
+When passphrase-based key derivation is used during encryption,
+*key derivation metadata* recording the algorithm used is stored
+in the tensorized file. Since the file keeps track of the algorithm,
+any `from_string()`-based encryption can be decrypted the same way:
+
+```py
+source: str = ...
+decryption_params = DecryptionParams.from_string(source)
+deserializer = TensorDeserializer(..., encryption=decryption_params)
+```
+
+Additionally, *any* encryption,
+whether a passphrase was used during encryption or not,
+can be decrypted if you know its exact binary key:
+
+```py
+key: bytes = ...
+decryption_params = DecryptionParams.from_key(key)
+deserializer = TensorDeserializer(..., encryption=decryption_params)
+```
+
+This is the only way to decrypt a file that was encrypted using
+`EncryptionParams.random()`, since it has no associated passphrase.
+
+To retrieve a binary key from an `EncryptionParams` object, access its `key`
+attribute:
+
+```py
+encryption_params = EncryptionParams.random()
+# Or
+encryption_params = EncryptionParams.from_string(...)
+
+key: bytes = encryption_params.key
+```
+
+## Speed
+
+The throughput of `tensorizer`'s encryption algorithm reaches 31 GiB/s on
+~26 cores, most likely limited by RAM or CPU cache speed.
+Since it can overlap with downloads, the time overhead of decryption is
+very small, with data-processed-to-latency-incurred rates in the terabit range
+encountered on test machines.
+
+Speed of key derivation is configurable, if used.
+See [Choosing a Key Derivation Algorithm](#choosing-a-key-derivation-algorithm).
+
+## Compatibility
+
+Tensors serialized with encryption are stored using Tensorizer data format v3,
+compatible to be read with `tensorizer>=2.7.0`.
diff --git a/examples/encrypt_existing.py b/examples/encrypt_existing.py
new file mode 100644
index 00000000..0686fc3d
--- /dev/null
+++ b/examples/encrypt_existing.py
@@ -0,0 +1,384 @@
+import argparse
+import binascii
+from contextlib import ExitStack
+
+
+def parse_args(argv=None) -> argparse.Namespace:
+    parser = argparse.ArgumentParser(
+        description=(
+            "add or remove encryption from an already-tensorized file. See"
+            " docs/encryption.md for an explanation."
+        )
+    )
+
+    subparsers = parser.add_subparsers(
+        help="whether to add or remove encryption"
+    )
+
+    LIMITS_METAVAR = "<SENSITIVE|MODERATE|INTERACTIVE|MIN|int>"
+
+    add_parser = subparsers.add_parser(
+        "add", description="add encryption to an already-tensorized file"
+    )
+    add_subparsers = add_parser.add_subparsers(
+        help="key derivation / generation method"
+    )
+
+    add_pwhash_parser = add_subparsers.add_parser(
+        "pwhash",
+        description=(
+            "encrypt using a key generated with Argon2id key derivation"
+        ),
+    )
+    add_pwhash_parser.set_defaults(func=add_pwhash)
+    add_pwhash_parser.add_argument(
+        "--keyfile",
+        type=argparse.FileType("rb"),
+        required=True,
+        help=(
+            "file holding data to process into an encryption key using Argon2id"
+        ),
+    )
+    add_pwhash_parser.add_argument(
+        "--no-strip-trailing-newlines",
+        dest="strip_trailing_newlines",
+        action="store_false",
+        default=True,
+        help="don't strip trailing newlines from the key file",
+    )
+
+    add_pwhash_parser.add_argument(
+        "--opslimit",
+        metavar=LIMITS_METAVAR,
+        type=str,
+        required=True,
+        help=(
+            "Argon2id opslimit (CPU time difficulty; param from libsodium's"
+            " pwhash function)"
+        ),
+    )
+    add_pwhash_parser.add_argument(
+        "--memlimit",
+        metavar=LIMITS_METAVAR,
+        type=str,
+        required=True,
+        help=(
+            "Argon2id memlimit (RAM difficulty; param from libsodium's pwhash"
+            " function)"
+        ),
+    )
+    add_pwhash_parser.add_argument(
+        "--salt",
+        type=str,
+        help=(
+            "hex representation of a custom 16-byte cryptographic salt to use"
+            " (randomly generated otherwise)"
+        ),
+    )
+
+    add_exact_key_parser = add_subparsers.add_parser(
+        "exact",
+        description="encrypt using an exact 32-byte binary key, unmodified",
+    )
+    add_exact_key_parser.set_defaults(func=add_exact_key)
+    add_exact_key_parser.add_argument(
+        "--keyfile",
+        type=argparse.FileType("rb"),
+        required=True,
+        help=(
+            "file holding exactly 32 bytes of binary data to use verbatim as an"
+            " encryption key"
+        ),
+    )
+
+    add_random_key_parser = add_subparsers.add_parser(
+        "random",
+        description=(
+            "encrypt using a random 32-byte binary key, and write the key to a"
+            " file"
+        ),
+    )
+    add_random_key_parser.set_defaults(func=add_random_key)
+    add_random_key_parser.add_argument(
+        "--keyfile",
+        type=argparse.FileType("wb"),
+        required=True,
+        help=(
+            "file to write 32 bytes of randomly-generated binary data used as"
+            " an encryption key"
+        ),
+    )
+
+    remove_parser = subparsers.add_parser(
+        "remove",
+        description="remove encryption from an already-tensorized file",
+    )
+    remove_subparsers = remove_parser.add_subparsers(
+        help="key derivation / generation method"
+    )
+
+    remove_pwhash_parser = remove_subparsers.add_parser(
+        "pwhash",
+        description=(
+            "decrypt using a key generated with Argon2id key derivation"
+        ),
+    )
+    remove_pwhash_parser.set_defaults(func=remove_pwhash)
+    remove_pwhash_parser.add_argument(
+        "--keyfile",
+        type=argparse.FileType("rb"),
+        required=True,
+        help=(
+            "file holding data to process into an encryption key using Argon2id"
+        ),
+    )
+    remove_pwhash_parser.add_argument(
+        "--no-strip-trailing-newlines",
+        dest="strip_trailing_newlines",
+        action="store_false",
+        default=True,
+        help="don't strip trailing newlines from the key file",
+    )
+
+    remove_exact_key_parser = remove_subparsers.add_parser(
+        "exact",
+        description="decrypt using an exact 32-byte binary key, unmodified",
+    )
+    remove_exact_key_parser.set_defaults(func=remove_exact_key)
+    remove_exact_key_parser.add_argument(
+        "--keyfile",
+        type=argparse.FileType("rb"),
+        required=True,
+        help=(
+            "file holding exactly 32 bytes of binary data to use verbatim as an"
+            " encryption key"
+        ),
+    )
+
+    for subparser in (
+        add_pwhash_parser,
+        add_exact_key_parser,
+        add_random_key_parser,
+        remove_pwhash_parser,
+        remove_exact_key_parser,
+    ):
+        subparser.add_argument(
+            "--infile", type=str, required=True, help="source file to convert"
+        )
+        subparser.add_argument(
+            "--outfile",
+            type=str,
+            required=True,
+            help="where to write the resulting converted file",
+        )
+        subparser.add_argument(
+            "-q",
+            "--quiet",
+            action="store_true",
+            default=False,
+            help="show less output",
+        )
+
+    args = parser.parse_args(argv)
+
+    if args.infile == args.outfile:
+        parser.error("--infile and --outfile can't be the same")
+
+    if args.func != add_random_key:
+        try:
+            args.key = args.keyfile.read()
+            args.keyfile.close()
+        except OSError:
+            parser.error("Provided --keyfile path could not be read")
+    else:
+        args.key = None
+
+    exact_key_length = 32
+
+    if args.func in (add_exact_key, remove_exact_key):
+        if len(args.key) != exact_key_length:
+            parser.error(
+                "Invalid key length:"
+                f" got {len(args.key)} bytes, expected {exact_key_length} bytes"
+            )
+    elif (
+        args.func in (add_pwhash, remove_pwhash)
+        and args.strip_trailing_newlines
+    ):
+        args.key = args.key.rstrip(b"\r\n")
+
+    salt_length = 16
+
+    if args.func == add_pwhash:
+        if args.salt is not None:
+            if len(args.salt) != salt_length * 2:
+                parser.error(
+                    f"Invalid --salt length (should be {salt_length} bytes ="
+                    f" {salt_length * 2} hex characters)"
+                )
+            try:
+                args.salt = binascii.unhexlify(args.salt)
+                assert len(args.salt) == salt_length
+            except binascii.Error:
+                parser.error("Invalid hexadecimal string provided for --salt")
+
+        limit_options = ("SENSITIVE", "MODERATE", "INTERACTIVE", "MIN")
+        args.opslimit = args.opslimit.upper()
+        args.memlimit = args.memlimit.upper()
+        try:
+            int(args.opslimit)
+        except ValueError:
+            if args.opslimit not in limit_options:
+                parser.error(
+                    "Invalid --opslimit, expected one of "
+                    + ", ".join(limit_options)
+                    + ", or an integer"
+                )
+        try:
+            int(args.memlimit)
+        except ValueError:
+            if args.memlimit not in limit_options:
+                parser.error(
+                    "Invalid --memlimit, expected one of "
+                    + ", ".join(limit_options)
+                    + ", or an integer"
+                )
+
+    return args
+
+
+def get_limit(value, enumeration) -> int:
+    try:
+        return int(value)
+    except ValueError:
+        value = getattr(enumeration, value, None)
+        if value is not None:
+            return value
+        else:
+            raise ValueError(
+                f"Unrecognized limit: {value}, available:"
+                f" {', '.join(v.name for v in enumeration)}"
+            )
+
+
+def add_pwhash(args: argparse.Namespace):
+    from tensorizer import EncryptionParams
+
+    opslimit = get_limit(args.opslimit, EncryptionParams.OpsLimit)
+    memlimit = get_limit(args.memlimit, EncryptionParams.MemLimit)
+    salt = args.salt
+    key: bytes = args.key
+    encryption_params = EncryptionParams.from_string(
+        source=key, opslimit=opslimit, memlimit=memlimit, salt=salt
+    )
+    add_encryption(encryption_params, args.infile, args.outfile, not args.quiet)
+    print("Salt:", binascii.hexlify(encryption_params.salt).decode("ascii"))
+
+
+def add_exact_key(args: argparse.Namespace):
+    from tensorizer import EncryptionParams
+
+    encryption_params = EncryptionParams(key=args.key)
+    add_encryption(encryption_params, args.infile, args.outfile, not args.quiet)
+
+
+def add_random_key(args: argparse.Namespace):
+    from tensorizer import EncryptionParams
+
+    encryption_params = EncryptionParams.random()
+    args.keyfile.write(encryption_params.key)
+    args.keyfile.close()
+    add_encryption(encryption_params, args.infile, args.outfile, not args.quiet)
+
+
+def add_encryption(
+    encryption_params, in_file: str, out_file: str, show_progress: bool = True
+):
+    from tensorizer import TensorDeserializer, TensorSerializer, TensorType
+
+    with ExitStack() as cleanup:
+        serializer = TensorSerializer(out_file, encryption=encryption_params)
+        cleanup.callback(serializer.close)
+        deserializer = TensorDeserializer(
+            in_file, device="cpu", lazy_load=True, verify_hash=True
+        )
+        cleanup.enter_context(deserializer)
+        count: int = len(deserializer.keys())
+        i = 1
+        for (
+            module_idx,
+            tensor_type,
+            name,
+            tensor,
+        ) in deserializer.read_tensors():
+            if show_progress:
+                print(f"({i} / {count}) Encrypting {name}")
+                i += 1
+            tensor_type = TensorType(tensor_type)
+            serializer.write_tensor(module_idx, name, tensor_type, tensor)
+            # Release memory
+            tensor.set_()
+            del tensor
+
+
+def remove_pwhash(args: argparse.Namespace):
+    from tensorizer import DecryptionParams
+
+    decryption_params = DecryptionParams.from_string(args.key)
+    remove_encryption(
+        decryption_params, args.infile, args.outfile, not args.quiet
+    )
+
+
+def remove_exact_key(args: argparse.Namespace):
+    from tensorizer import DecryptionParams
+
+    decryption_params = DecryptionParams.from_key(args.key)
+    remove_encryption(
+        decryption_params, args.infile, args.outfile, not args.quiet
+    )
+
+
+def remove_encryption(
+    decryption_params, in_file: str, out_file: str, show_progress: bool = True
+):
+    from tensorizer import TensorDeserializer, TensorSerializer, TensorType
+
+    with ExitStack() as cleanup:
+        serializer = TensorSerializer(out_file)
+        cleanup.callback(serializer.close)
+        deserializer = TensorDeserializer(
+            in_file,
+            device="cpu",
+            lazy_load=True,
+            verify_hash=True,
+            encryption=decryption_params,
+        )
+        cleanup.enter_context(deserializer)
+        count: int = len(deserializer.keys())
+        i = 1
+        for (
+            module_idx,
+            tensor_type,
+            name,
+            tensor,
+        ) in deserializer.read_tensors():
+            if show_progress:
+                print(f"({i} / {count}) Decrypting {name}")
+                i += 1
+            tensor_type = TensorType(tensor_type)
+            serializer.write_tensor(module_idx, name, tensor_type, tensor)
+            # Release memory
+            tensor.set_()
+            del tensor
+
+
+def main(argv=None):
+    args = parse_args(argv)
+    args.func(args)
+    if not args.quiet:
+        print("Done")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/examples/encryption.py b/examples/encryption.py
new file mode 100644
index 00000000..5be828f1
--- /dev/null
+++ b/examples/encryption.py
@@ -0,0 +1,92 @@
+import os
+import tempfile
+import time
+
+import torch
+from transformers import AutoConfig, AutoModelForCausalLM
+
+from tensorizer import (
+    DecryptionParams,
+    EncryptionParams,
+    TensorDeserializer,
+    TensorSerializer,
+)
+from tensorizer.utils import no_init_or_tensor
+
+model_ref = "EleutherAI/gpt-neo-2.7B"
+
+
+def original_model(ref) -> torch.nn.Module:
+    return AutoModelForCausalLM.from_pretrained(ref)
+
+
+def empty_model(ref) -> torch.nn.Module:
+    config = AutoConfig.from_pretrained(ref)
+    with no_init_or_tensor():
+        return AutoModelForCausalLM.from_config(config)
+
+
+# Set a strong string or bytes passphrase here
+source: str = os.getenv("SUPER_SECRET_STRONG_PASSWORD", "") or input(
+    "Source string to create an encryption key: "
+)
+
+fd, path = tempfile.mkstemp(prefix="encrypted-tensors")
+
+try:
+    # Encrypt a model during serialization
+    encryption_params = EncryptionParams.from_string(source)
+
+    model = original_model(model_ref)
+    serialization_start = time.monotonic()
+
+    serializer = TensorSerializer(path, encryption=encryption_params)
+    serializer.write_module(model)
+    serializer.close()
+
+    serialization_end = time.monotonic()
+    del model
+
+    # Then decrypt it again during deserialization
+    decryption_params = DecryptionParams.from_string(source)
+
+    model = empty_model(model_ref)
+    deserialization_start = time.monotonic()
+
+    deserializer = TensorDeserializer(
+        path, encryption=decryption_params, plaid_mode=True
+    )
+    deserializer.load_into_module(model)
+    deserializer.close()
+
+    deserialization_end = time.monotonic()
+    del model
+finally:
+    os.close(fd)
+    os.unlink(path)
+
+
+def print_speed(prefix, start, end, size):
+    mebibyte = 1 << 20
+    gibibyte = 1 << 30
+    duration = end - start
+    rate = size / duration
+    print(
+        f"{prefix} {size / gibibyte:.2f} GiB model in {duration:.2f} seconds,"
+        f" {rate / mebibyte:.2f} MiB/s"
+    )
+
+
+print_speed(
+    "Serialized and encrypted",
+    serialization_start,
+    serialization_end,
+    serializer.total_tensor_bytes,
+)
+
+print_speed(
+    "Deserialized encrypted",
+    deserialization_start,
+    deserialization_end,
+    deserializer.total_tensor_bytes,
+)
diff --git a/pyproject.toml b/pyproject.toml
index 1a478ce1..1668b3d0 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -16,7 +16,8 @@ dependencies = [
     "psutil>=5.9.4",
     "boto3>=1.26.0",
     "redis>=5.0.0",
-    "hiredis>=2.2.0"
+    "hiredis>=2.2.0",
+    "libnacl>=2.1.0"
 ]
 classifiers = [
     "Programming Language :: Python :: 3",
diff --git a/requirements.txt b/requirements.txt
index 2e32128b..9bd5aafe 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -3,5 +3,6 @@ numpy>=1.19.5
 protobuf>=3.19.5
 psutil>=5.9.4
 boto3>=1.26.0
+redis==5.0.0
 hiredis
-redis==5.0.0
\ No newline at end of file
+libnacl>=2.1.0
diff --git a/tensorizer/_NumpyTensor.py b/tensorizer/_NumpyTensor.py
index b4d6dd8a..37cb62c2 100644
--- a/tensorizer/_NumpyTensor.py
+++ b/tensorizer/_NumpyTensor.py
@@ -13,39 +13,74 @@
     8: torch.int64,
 }
 
-# torch types with no numpy equivalents
-# i.e. the only ones that need to be opaque
-# Uses a comprehension to filter out any dtypes
-# that don't exist in older torch versions
-_ASYMMETRIC_TYPES = {
-    getattr(torch, t)
+# Listing of types from a static copy of:
+# tuple(
+#     dict.fromkeys(
+#         str(t)
+#         for t in vars(torch).values()
+#         if isinstance(t, torch.dtype)
+#     )
+# )
+_ALL_TYPES = {
+    f"torch.{t}": v
     for t in (
-        "bfloat16",
-        "quint8",
+        "uint8",
+        "int8",
+        "int16",
+        "int32",
+        "int64",
+        "float16",
+        "float32",
+        "float64",
+        "complex32",
+        "complex64",
+        "complex128",
+        "bool",
         "qint8",
+        "quint8",
         "qint32",
+        "bfloat16",
         "quint4x2",
         "quint2x4",
-        "complex32",
     )
-    if hasattr(torch, t)
+    if isinstance(v := getattr(torch, t, None), torch.dtype)
+}
+
+# torch types with no numpy equivalents
+# i.e. the only ones that need to be opaque
+# Uses a comprehension to filter out any dtypes
+# that don't exist in older torch versions
+_ASYMMETRIC_TYPES = {
+    _ALL_TYPES[t]
+    for t in {
+        "torch.bfloat16",
+        "torch.quint8",
+        "torch.qint8",
+        "torch.qint32",
+        "torch.quint4x2",
+        "torch.quint2x4",
+        "torch.complex32",
+    }
+    & _ALL_TYPES.keys()
 }
 
 # These types aren't supported yet because they require supplemental
 # quantization parameters to deserialize correctly
 _UNSUPPORTED_TYPES = {
-    getattr(torch, t)
-    for t in (
-        "quint8",
-        "qint8",
-        "qint32",
-        "quint4x2",
-        "quint2x4",
-    )
-    if hasattr(torch, t)
+    _ALL_TYPES[t]
+    for t in {
+        "torch.quint8",
+        "torch.qint8",
+        "torch.qint32",
+        "torch.quint4x2",
+        "torch.quint2x4",
+    }
+    & _ALL_TYPES.keys()
 }
 
-_DECODE_MAPPING = {str(t): t for t in _ASYMMETRIC_TYPES}
+_DECODE_MAPPING = {
+    k: v for k, v in _ALL_TYPES.items() if v not in _UNSUPPORTED_TYPES
+}
 
 
 class _NumpyTensor(NamedTuple):
@@ -85,14 +120,12 @@ def from_buffer(
             buffer=buffer,
             offset=offset,
         )
-        return cls(data=data,
-                   numpy_dtype=numpy_dtype,
-                   torch_dtype=torch_dtype)
+        return cls(data=data, numpy_dtype=numpy_dtype, torch_dtype=torch_dtype)
 
     @classmethod
-    def from_tensor(cls,
-                    tensor: Union[torch.Tensor,
-                                  torch.nn.Module]) -> "_NumpyTensor":
+    def from_tensor(
+        cls, tensor: Union[torch.Tensor, torch.nn.Module]
+    ) -> "_NumpyTensor":
         """
         Converts a torch tensor into a `_NumpyTensor`.
         May use an opaque dtype for the numpy array stored in
diff --git a/tensorizer/_crypt/__init__.py b/tensorizer/_crypt/__init__.py
new file mode 100644
index 00000000..67462fc2
--- /dev/null
+++ b/tensorizer/_crypt/__init__.py
@@ -0,0 +1,39 @@
+"""
+Internal cryptographic library for tensorizer.
+These functions are only meant to be used by tensorizer itself,
+and are not guaranteed to have a stable interface across versions.
+"""
+
+__all__ = (
+    "available",
+    "ChunkedEncryption",
+    "Const",
+    "CryptographyError",
+    "PWHash",
+    "random_bytes",
+)
+
+from ._exceptions import CryptographyError
+
+try:
+    from ._encryption import (
+        ChunkedEncryption,
+        Const,
+        PWHash,
+        SequentialEncryption,
+        random_bytes,
+    )
+
+    available: bool = True
+
+
+except (OSError, AttributeError):
+    available: bool = False
+
+    def __getattr__(name):
+        if name in __all__:
+            raise RuntimeError(
+                "Encryption module was not initialized,"
+                " make sure a recent version of libsodium is installed"
+            )
+        raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/tensorizer/_crypt/__main__.py b/tensorizer/_crypt/__main__.py
new file mode 100644
index 00000000..4de88e4a
--- /dev/null
+++ b/tensorizer/_crypt/__main__.py
@@ -0,0 +1,392 @@
+import argparse
+import contextlib
+import json
+import mmap
+import pathlib
+import time
+from hashlib import sha256
+from typing import Optional, Union
+
+from ._cgroup_cpu_count import effective_cpu_count
+from ._encryption import (
+    AsymmetricParams,
+    ChunkedEncryption,
+    SequentialEncryption,
+    SymmetricParams,
+    as_ucstr,
+    crypto_box_detached,
+    crypto_box_open_detached,
+    crypto_secretbox_detached,
+    crypto_secretbox_open_detached,
+)
+
+cpu_count: int = effective_cpu_count()
+
+parser = argparse.ArgumentParser(
+    description="internal command for testing encryption performance"
+)
+parser.add_argument(
+    "--generate",
+    metavar="NUMBYTES",
+    type=int,
+    nargs="?",
+    default=None,
+    help="generate a message to test with this size (default: 256 MiB)",
+)
+parser.add_argument(
+    "-f",
+    "--file",
+    type=pathlib.Path,
+    default=None,
+    help=(
+        "encrypt or decrypt a file inplace"
+        " (requires --crypt-info as well as one of --encrypt or --decrypt)"
+    ),
+)
+parser.set_defaults(encrypt=None)
+parser.add_argument(
+    "--encrypt", action="store_true", help="encrypt a file (requires --file)"
+)
+parser.add_argument(
+    "--decrypt",
+    action="store_false",
+    dest="encrypt",
+    help="decrypt a file (requires --file)",
+)
+parser.add_argument(
+    "-i",
+    "--crypt-info",
+    type=pathlib.Path,
+    default=None,
+    help="path to store and retrieve key, nonces, and MACs (requires --file)",
+)
+parser.add_argument(
+    "--chunk-size",
+    type=int,
+    default=2 << 20,
+    help="chunk size for parallel encryption",
+)
+parser.add_argument(
+    "-d",
+    "--delayed",
+    action="store_true",
+    help="enable delayed verification",
+)
+parser.add_argument(
+    "-t",
+    "--threads",
+    type=int,
+    default=cpu_count,
+    help=(
+        "maximum number of threads for parallel encryption"
+        f" (default: {cpu_count})"
+    ),
+)
+args = parser.parse_args()
+
+if args.file is not None:
+    if args.generate is not None:
+        parser.error("Cannot specify both --file and --generate")
+    if args.encrypt is None:
+        parser.error("Must specify either --encrypt or --decrypt with --file")
+    if args.crypt_info is None:
+        parser.error("Must specify --crypt-info with --file")
+    if not args.encrypt:
+        if not args.file.is_file():
+            parser.error("--file path is not a file and cannot be decrypted")
+        if not args.crypt_info.is_file():
+            parser.error("--crypt-info path is not a file")
+else:
+    if (args.crypt_info, args.encrypt) != (None, None):
+        parser.error(
+            "--crypt-info, --encrypt, and --decrypt are only valid"
+            " when used with --file"
+        )
+    if args.generate is None:
+        args.generate = 256 << 20
+
+
+class Timer(contextlib.AbstractContextManager):
+    def __init__(self):
+        self.start: Optional[float] = None
+        self.end: Optional[float] = None
+        self.elapsed: Optional[float] = None
+
+    def __enter__(self):
+        self.start = time.monotonic()
+        return super().__enter__()
+
+    def __exit__(self, __exc_type, __exc_value, __traceback):
+        self.end = time.monotonic()
+        self.elapsed = self.end - self.start
+
+    def rate(self, count) -> Union[float, str]:
+        return "\u221e" if self.elapsed == 0 else count / self.elapsed
+
+
+def num_chunks(total_size: int, chunk_size: int) -> int:
+    return (total_size // chunk_size) + (total_size % chunk_size != 0)
+
+
+def preview(text, length: int, as_hex: bool = False):
+    with memoryview(text) as mv:
+        truncated = mv[:length].hex() if as_hex else str(bytes(mv[:length]))
+        return truncated + ("..." if len(mv) > length else "")
+
+
+def sequential_test(
+    buffer: Union[bytearray, memoryview], asymmetric: bool = False
+):
+    if asymmetric:
+        params = AsymmetricParams.random()
+        key_views = {"pk": as_ucstr(params.pk), "sk": as_ucstr(params.sk)}
+        encrypt = crypto_box_detached
+        decrypt = crypto_box_open_detached
+    else:
+        params = SymmetricParams.random()
+        key_views = {"k": as_ucstr(params.k)}
+        encrypt = crypto_secretbox_detached
+        decrypt = crypto_secretbox_open_detached
+    buffer_view = as_ucstr(buffer)
+    mac_view = as_ucstr(params.mac)
+    nonce_view = as_ucstr(params.nonce)
+    m_len = len(buffer)
+
+    timer = Timer()
+    mebibyte = 1 << 20
+    with timer:
+        encrypt(
+            c=buffer_view,
+            mac=mac_view,
+            m=buffer_view,
+            mlen=m_len,
+            n=nonce_view,
+            **key_views,
+        )
+
+    print(
+        f"Encrypted {m_len} bytes in {timer.elapsed:.6f} seconds,"
+        f" {timer.rate(m_len / mebibyte)} MiB/s",
+        f"Contents: {preview(buffer, 32, True)}",
+        f"MAC: {params.mac.hex()}",
+        sep="\n",
+    )
+
+    with timer:
+        decrypt(
+            m=buffer_view,
+            c=buffer_view,
+            mac=mac_view,
+            clen=m_len,
+            n=nonce_view,
+            **key_views,
+        )
+
+    print(
+        f"Decrypted {m_len} bytes in {timer.elapsed:.6f} seconds,"
+        f" {timer.rate(m_len / mebibyte)} MiB/s",
+        preview(buffer, 64),
+        sep="\n",
+    )
+
+
+def symmetric_sequential_test(
+    buffer: Union[bytearray, memoryview], key: Optional[bytes] = None
+):
+    if key is None:
+        key = SymmetricParams.random().k
+    timer = Timer()
+    mebibyte = 1 << 20
+
+    crypto = SequentialEncryption(key, buffer)
+    with timer:
+        crypto.encrypt()
+
+    print(
+        f"Encrypted {len(buffer)} bytes"
+        f" in {timer.elapsed:.6f} seconds,"
+        f" {timer.rate(len(buffer) / mebibyte)} MiB/s",
+        f"Contents: {preview(buffer, 32, True)}",
+        f"MACs: {preview(crypto.mac, 32, True)}",
+        sep="\n",
+    )
+
+    with timer:
+        crypto.decrypt()
+
+    print(
+        f"Decrypted {len(buffer)} bytes"
+        f" in {timer.elapsed:.6f} seconds,"
+        f" {timer.rate(len(buffer) / mebibyte)} MiB/s",
+        preview(buffer, 64),
+        sep="\n",
+    )
+
+
+def parallel_test(
+    buffer: Union[bytearray, memoryview],
+    chunk_size: int,
+    automatic_verification: bool,
+    num_threads: int,
+    key: Optional[bytes] = None,
+):
+    if key is None:
+        key = SymmetricParams.random().k
+
+    timer = Timer()
+    mebibyte = 1 << 20
+
+    with ChunkedEncryption(
+        key,
+        buffer,
+        chunk_size,
+        num_threads=num_threads,
+        automatic_verification=automatic_verification,
+    ) as crypto:
+        with timer:
+            crypto.encrypt_all(wait=True, timeout=None)
+
+        macs = crypto.concatenated_macs()
+
+        print(
+            f"Encrypted {len(buffer)} bytes"
+            f" in {timer.elapsed:.6f} seconds,"
+            f" {timer.rate(len(buffer) / mebibyte)} MiB/s",
+            f"Contents: {preview(buffer, 32, True)}",
+            f"MACs: {preview(macs, 32, True)}",
+            sep="\n",
+        )
+
+        with timer:
+            crypto.decrypt_all(wait=True, timeout=None)
+
+        print(
+            f"Decrypted {len(buffer)} bytes"
+            f" in {timer.elapsed:.6f} seconds,"
+            f" {timer.rate(len(buffer) / mebibyte)} MiB/s",
+            preview(buffer, 64),
+            sep="\n",
+        )
+
+
+def parallel_transform_file(
+    encrypt: bool,
+    path: pathlib.Path,
+    crypt_info_path: pathlib.Path,
+    chunk_size: int,
+    automatic_verification: bool,
+    num_threads: int,
+):
+    with contextlib.ExitStack() as context:
+        file = context.enter_context(path.open("rb+"))
+        buffer = context.enter_context(mmap.mmap(file.fileno(), 0))
+        context.callback(buffer.flush)
+        num_bytes = len(buffer)
+        context = context.pop_all()
+    if encrypt:
+        params = SymmetricParams.random()
+        key = params.k
+        nonces = None
+        macs = None
+    else:
+        with crypt_info_path.open("rb") as crypt_info_file:
+            json_params = json.load(crypt_info_file)
+        key = bytes.fromhex(json_params["key"])
+        nonces = tuple(map(bytes.fromhex, json_params["nonces"]))
+        macs = tuple(map(bytes.fromhex, json_params["macs"]))
+
+    timer = Timer()
+    mebibyte = 1 << 20
+
+    with context, ChunkedEncryption(
+        key,
+        buffer,
+        chunk_size,
+        nonces=nonces,
+        macs=macs,
+        num_threads=num_threads,
+        automatic_verification=automatic_verification,
+    ) as crypto:
+        try:
+            if encrypt:
+                with timer:
+                    crypto.encrypt_all(wait=True, timeout=None)
+            else:
+                with timer:
+                    crypto.decrypt_all(wait=True, timeout=None)
+            macs = crypto.macs
+            del crypto
+        finally:
+            import gc
+
+            gc.collect()
+
+    if encrypt:
+        with crypt_info_path.open("w") as crypt_info_file:
+            json_params = {
+                "key": key.hex(),
+                "nonces": [n.hex() for n in nonces],
+                "macs": [m.hex() for m in macs],
+            }
+            json.dump(json_params, crypt_info_file)
+
+    print(
+        f"{'Encrypted' if encrypt else 'Decrypted'}"
+        f" {num_bytes} bytes in {timer.elapsed:.6f} seconds"
+        f" {timer.rate(num_bytes / mebibyte)} MiB/s"
+    )
+
+
+def run_tests(
+    message: bytearray,
+    chunk_size: int,
+    threads: int,
+    automatic_verification: bool,
+):
+    original_hash = sha256(message).digest()
+
+    print("Asymmetric")
+    sequential_test(message, asymmetric=True)
+
+    assert sha256(message).digest() == original_hash
+
+    print("\nSymmetric")
+    sequential_test(message, asymmetric=False)
+
+    assert sha256(message).digest() == original_hash
+
+    key: bytes = SymmetricParams.random().k
+
+    print("\nSymmetric (OO)")
+    symmetric_sequential_test(message, key)
+
+    assert sha256(message).digest() == original_hash
+
+    print("\nParallel")
+    parallel_test(
+        message,
+        chunk_size=chunk_size,
+        automatic_verification=automatic_verification,
+        num_threads=threads,
+        key=key,
+    )
+
+    assert sha256(message).digest() == original_hash
+
+
+if args.generate is not None:
+    message = bytearray(b"Hello, World!")
+    message_size = args.generate
+    message *= num_chunks(message_size, len(message))
+    message[message_size:] = b""
+    assert len(message) == message_size
+    run_tests(message, args.chunk_size, args.threads, not args.delayed)
+else:
+    parallel_transform_file(
+        args.encrypt,
+        args.file,
+        args.crypt_info,
+        chunk_size=args.chunk_size,
+        automatic_verification=not args.delayed,
+        num_threads=args.threads,
+    )
diff --git a/tensorizer/_crypt/_cgroup_cpu_count.py b/tensorizer/_crypt/_cgroup_cpu_count.py
new file mode 100644
index 00000000..4f644c4e
--- /dev/null
+++ b/tensorizer/_crypt/_cgroup_cpu_count.py
@@ -0,0 +1,73 @@
+import enum
+import os
+import pathlib
+import sys
+from fractions import Fraction
+from functools import lru_cache
+from typing import Optional, Union
+
+__all__ = ("effective_cpu_count", "RoundingMode")
+
+
+class RoundingMode(enum.Enum):
+    UP = 1
+    DOWN = 2
+    HALF_EVEN = 3
+
+
+if sys.platform == "linux":
+
+    @lru_cache(maxsize=None)
+    def _cpu_quota() -> Optional[Fraction]:
+        cgroup = pathlib.Path("/sys/fs/cgroup")
+        cgroup_v1 = cgroup / "cpu,cpuacct"
+        cgroup_v2 = cgroup / "user.slice" / "cpu.max"
+        try:
+            if not cgroup.is_dir():
+                return None
+            elif cgroup_v1.is_dir():
+                quota, period = (
+                    (cgroup_v1 / p).read_text()
+                    for p in ("cpu.cfs_quota_us", "cpu.cfs_period_us")
+                )
+            elif cgroup_v2.is_file():
+                quota, period = cgroup_v2.read_text().split()
+            else:
+                raise OSError()
+
+            if quota == "max":
+                return None
+
+            q, p = map(int, (quota, period))
+            if q > 0 and p > 0:
+                return Fraction(q, p)
+            else:
+                raise ValueError()
+        except (OSError, ValueError):
+            return None
+
+else:
+
+    def _cpu_quota() -> None:
+        return None
+
+
+def effective_cpu_count(
+    rounding: Optional[RoundingMode] = RoundingMode.UP,
+) -> Union[int, Fraction]:
+    if not isinstance(rounding, (RoundingMode, type(None))):
+        raise TypeError("Invalid type for rounding mode")
+    quota: Optional[Fraction] = _cpu_quota()
+    if quota is None:
+        return os.cpu_count()
+    if rounding is None:
+        return quota
+    else:
+        if rounding is RoundingMode.UP:
+            return quota.__ceil__()
+        elif rounding is RoundingMode.DOWN:
+            return quota.__floor__()
+        elif rounding is RoundingMode.HALF_EVEN:
+            return round(quota)
+        else:
+            raise ValueError("Unknown rounding mode")
diff --git a/tensorizer/_crypt/_encryption.py b/tensorizer/_crypt/_encryption.py
new file mode 100644
index 00000000..dc519a44
--- /dev/null
+++ b/tensorizer/_crypt/_encryption.py
@@ -0,0 +1,1132 @@
+import concurrent.futures
+import ctypes
+import dataclasses
+import enum
+import io
+import mmap
+import typing
+from concurrent.futures import ThreadPoolExecutor
+from contextlib import AbstractContextManager
+from ctypes import (
+    POINTER,
+    c_char_p,
+    c_int,
+    c_size_t,
+    c_ubyte,
+    c_uint64,
+    c_ulonglong,
+    c_void_p,
+)
+from typing import (
+    ClassVar,
+    Final,
+    Iterable,
+    Iterator,
+    NamedTuple,
+    Optional,
+    Tuple,
+    Union,
+)
+
+import libnacl
+from libnacl import nacl
+
+try:
+    from ._cgroup_cpu_count import effective_cpu_count
+    from ._exceptions import CryptographyError
+except ImportError:
+    from _cgroup_cpu_count import effective_cpu_count
+    from _exceptions import CryptographyError
+
+IN, OUT, INOUT = 1, 2, 3
+
+c_ubyte_p = POINTER(c_ubyte)
+
+
+def error_handler(failure_message: str = "Cryptographic operation failed"):
+    def _errcheck(result, func, args):
+        del args  # Break any references to ctypes buffers
+        if result == -1:
+            raise CryptographyError(failure_message)
+        elif result == 0:
+            return None
+        else:
+            raise RuntimeError("Unknown return code")
+
+    return _errcheck
+
+
+errcheck = error_handler()
+encrypt_errcheck = error_handler("Encryption failed")
+decrypt_errcheck = error_handler("Decryption failed: MAC doesn't match data")
+
+
+def init_asymmetric():
+    # https://libsodium.gitbook.io/doc/public-key_cryptography/authenticated_encryption
+    prototype = ctypes.CFUNCTYPE(
+        c_int,
+        c_ubyte_p,
+        c_ubyte_p,
+        c_ubyte_p,
+        c_ulonglong,
+        c_ubyte_p,
+        c_ubyte_p,
+        c_ubyte_p,
+    )
+
+    crypto_box_detached_paramflags = (
+        (INOUT, "c"),
+        (INOUT, "mac"),
+        (IN, "m"),
+        (IN, "mlen"),
+        (IN, "n"),
+        (IN, "pk"),
+        (IN, "sk"),
+    )
+
+    crypto_box_open_detached_paramflags = (
+        (INOUT, "m"),
+        (IN, "c"),
+        (IN, "mac"),
+        (IN, "clen"),
+        (IN, "n"),
+        (IN, "pk"),
+        (IN, "sk"),
+    )
+
+    crypto_box_detached = prototype(
+        ("crypto_box_detached", nacl), crypto_box_detached_paramflags
+    )
+    crypto_box_open_detached = prototype(
+        ("crypto_box_open_detached", nacl), crypto_box_open_detached_paramflags
+    )
+
+    crypto_box_detached.errcheck = encrypt_errcheck
+    crypto_box_open_detached.errcheck = decrypt_errcheck
+
+    return crypto_box_detached, crypto_box_open_detached
+
+
+def init_symmetric():
+    # https://libsodium.gitbook.io/doc/secret-key_cryptography/secretbox
+    prototype = ctypes.CFUNCTYPE(
+        c_int,
+        c_ubyte_p,
+        c_ubyte_p,
+        c_ubyte_p,
+        c_ulonglong,
+        c_ubyte_p,
+        c_ubyte_p,
+    )
+
+    crypto_secretbox_detached_paramflags = (
+        (INOUT, "c"),
+        (INOUT, "mac"),
+        (IN, "m"),
+        (IN, "mlen"),
+        (IN, "n"),
+        (IN, "k"),
+    )
+
+    crypto_secretbox_open_detached_paramflags = (
+        (INOUT, "m"),
+        (IN, "c"),
+        (IN, "mac"),
+        (IN, "clen"),
+        (IN, "n"),
+        (IN, "k"),
+    )
+
+    crypto_secretbox_detached = prototype(
+        ("crypto_secretbox_detached", nacl),
+        crypto_secretbox_detached_paramflags,
+    )
+    crypto_secretbox_open_detached = prototype(
+        ("crypto_secretbox_open_detached", nacl),
+        crypto_secretbox_open_detached_paramflags,
+    )
+
+    crypto_secretbox_detached.errcheck = encrypt_errcheck
+    crypto_secretbox_open_detached.errcheck = decrypt_errcheck
+
+    return crypto_secretbox_detached, crypto_secretbox_open_detached
+
+
+def init_sodium_increment():
+    # https://libsodium.gitbook.io/doc/helpers#incrementing-large-numbers
+    prototype = ctypes.CFUNCTYPE(
+        None,
+        c_ubyte_p,
+        c_size_t,
+    )
+
+    paramflags = (
+        (INOUT, "n"),
+        (IN, "nlen"),
+    )
+
+    sodium_increment = prototype(
+        ("sodium_increment", nacl),
+        paramflags,
+    )
+
+    return sodium_increment
+
+
+def init_randombytes_buf():
+    # https://libsodium.gitbook.io/doc/generating_random_data
+    prototype = ctypes.CFUNCTYPE(
+        None,
+        c_ubyte_p,
+        c_size_t,
+    )
+
+    paramflags = (
+        (INOUT, "buf"),
+        (IN, "size"),
+    )
+
+    randombytes_buf = prototype(
+        ("randombytes_buf", nacl),
+        paramflags,
+    )
+
+    return randombytes_buf
+
+
+def init_crypto_pwhash():
+    # https://libsodium.gitbook.io/doc/password_hashing/default_phf
+    prototype = ctypes.CFUNCTYPE(
+        c_int,
+        c_ubyte_p,
+        c_ulonglong,
+        c_char_p,
+        c_ulonglong,
+        c_ubyte_p,
+        c_ulonglong,
+        c_size_t,
+        c_int,
+    )
+
+    paramflags = (
+        (INOUT, "out"),
+        (IN, "outlen"),
+        (IN, "passwd"),
+        (IN, "passwdlen"),
+        (IN, "salt"),
+        (IN, "opslimit"),
+        (IN, "memlimit"),
+        (IN, "alg"),
+    )
+
+    crypto_pwhash = prototype(
+        ("crypto_pwhash", nacl),
+        paramflags,
+    )
+
+    crypto_pwhash.errcheck = error_handler(
+        "Password hashing failed. May have run out of memory."
+    )
+
+    return crypto_pwhash
+
+
+def init_crypto_onetimeauth_poly1305():
+    # https://libsodium.gitbook.io/doc/advanced/poly1305
+    prototype = ctypes.CFUNCTYPE(
+        c_int,
+        c_ubyte_p,
+        c_ubyte_p,
+        c_ulonglong,
+        c_ubyte_p,
+    )
+
+    paramflags = (
+        (INOUT, "out"),
+        (IN, "in_buf"),  # Actually called "in", but "in" is a Python keyword
+        (IN, "inlen"),
+        (IN, "k"),
+    )
+
+    crypto_onetimeauth_poly1305 = prototype(
+        ("crypto_onetimeauth_poly1305", nacl),
+        paramflags,
+    )
+
+    crypto_onetimeauth_poly1305.errcheck = errcheck
+
+    return crypto_onetimeauth_poly1305
+
+
+def init_crypto_core_hsalsa20():
+    prototype = ctypes.CFUNCTYPE(
+        c_int,
+        c_ubyte_p,
+        c_ubyte_p,
+        c_ubyte_p,
+        c_ubyte_p,
+    )
+
+    paramflags = (
+        (INOUT, "out"),
+        (IN, "in_buf"),  # Actually called "in", but "in" is a Python keyword
+        (IN, "k"),
+        (IN, "c", None),
+    )
+
+    crypto_core_hsalsa20 = prototype(
+        ("crypto_core_hsalsa20", nacl),
+        paramflags,
+    )
+
+    crypto_core_hsalsa20.errcheck = errcheck
+
+    return crypto_core_hsalsa20
+
+
+def init_crypto_stream_salsa20_xor_ic():
+    # https://libsodium.gitbook.io/doc/advanced/stream_ciphers/salsa20
+    prototype = ctypes.CFUNCTYPE(
+        c_int,
+        c_ubyte_p,
+        c_ubyte_p,
+        c_ulonglong,
+        c_ubyte_p,
+        c_uint64,
+        c_ubyte_p,
+    )
+
+    paramflags = (
+        (INOUT, "c"),
+        (IN, "m"),
+        (IN, "mlen"),
+        (IN, "n"),
+        (IN, "ic"),
+        (IN, "k"),
+    )
+
+    crypto_stream_salsa20_xor_ic = prototype(
+        ("crypto_stream_salsa20_xor_ic", nacl),
+        paramflags,
+    )
+
+    crypto_stream_salsa20_xor_ic.errcheck = errcheck
+
+    return crypto_stream_salsa20_xor_ic
+
+
+def init_sodium_memzero():
+    prototype = ctypes.CFUNCTYPE(
+        None,
+        c_void_p,
+        c_size_t,
+    )
+
+    paramflags = (
+        (IN, "pnt"),
+        (IN, "len"),
+    )
+
+    sodium_memzero = prototype(
+        ("sodium_memzero", nacl),
+        paramflags,
+    )
+
+    return sodium_memzero
+
+
+crypto_box_detached, crypto_box_open_detached = init_asymmetric()
+crypto_secretbox_detached, crypto_secretbox_open_detached = init_symmetric()
+sodium_increment = init_sodium_increment()
+randombytes_buf = init_randombytes_buf()
+crypto_pwhash = init_crypto_pwhash()
+crypto_onetimeauth_poly1305 = init_crypto_onetimeauth_poly1305()
+crypto_core_hsalsa20 = init_crypto_core_hsalsa20()
+crypto_stream_salsa20_xor_ic = init_crypto_stream_salsa20_xor_ic()
+sodium_memzero = init_sodium_memzero()
+
+
+class Constants(type):
+    @staticmethod
+    def _get_constant(name, typ) -> int:
+        prototype = ctypes.CFUNCTYPE(typ)
+        paramflags = ()
+        getter = prototype((name, nacl), paramflags)
+        return getter()
+
+    def __new__(cls, name: str, bases: tuple, dct: dict) -> NamedTuple:
+        annotations = dct.pop("__annotations__", {})
+        entries = {}
+        for constant_name, constant_type in dct.items():
+            if constant_name.startswith("_"):
+                continue
+            entries[constant_name] = cls._get_constant(
+                constant_name.lower(), constant_type
+            )
+        constant_class = typing.cast(
+            type, NamedTuple(name, **{k: annotations[k] for k in entries})
+        )
+        return constant_class(**entries)
+
+
+class Const(metaclass=Constants):
+    # Dynamically pulls constants from libsodium
+    # Syntax:
+    # libsodium name: Python type = C type
+
+    crypto_stream_salsa20_NONCEBYTES: int = c_size_t
+    crypto_stream_salsa20_KEYBYTES: int = c_size_t
+
+    crypto_onetimeauth_poly1305_BYTES: int = c_size_t
+    crypto_onetimeauth_poly1305_KEYBYTES: int = c_size_t
+
+    crypto_pwhash_SALTBYTES: int = c_size_t
+    crypto_pwhash_PASSWD_MIN: int = c_size_t
+    crypto_pwhash_PASSWD_MAX: int = c_size_t
+    crypto_pwhash_BYTES_MIN: int = c_size_t
+    crypto_pwhash_BYTES_MAX: int = c_size_t
+
+    crypto_pwhash_OPSLIMIT_MIN: int = c_ulonglong
+    crypto_pwhash_OPSLIMIT_INTERACTIVE: int = c_ulonglong
+    crypto_pwhash_OPSLIMIT_MODERATE: int = c_ulonglong
+    crypto_pwhash_OPSLIMIT_SENSITIVE: int = c_ulonglong
+    crypto_pwhash_OPSLIMIT_MAX: int = c_ulonglong
+
+    crypto_pwhash_MEMLIMIT_MIN: int = c_size_t
+    crypto_pwhash_MEMLIMIT_INTERACTIVE: int = c_size_t
+    crypto_pwhash_MEMLIMIT_MODERATE: int = c_size_t
+    crypto_pwhash_MEMLIMIT_SENSITIVE: int = c_size_t
+    crypto_pwhash_MEMLIMIT_MAX: int = c_size_t
+
+    crypto_pwhash_ALG_ARGON2ID13: int = c_int
+
+
+if (
+    Const.crypto_stream_salsa20_KEYBYTES
+    != Const.crypto_onetimeauth_poly1305_KEYBYTES
+):
+    # The definitions of these two algorithms should set these
+    # both at 32 bytes.
+    raise RuntimeError(
+        "crypto_stream_salsa20_KEYBYTES doesn't match"
+        " crypto_onetimeauth_poly1305_KEYBYTES"
+    )
+
+
+SALSA20_BLOCK_SIZE: Final[int] = 64
+
+Buffer = Union[bytes, bytearray, memoryview]
+MutableBuffer = Union[bytearray, memoryview]
+
+
+def as_ucstr(buffer):
+    if isinstance(buffer, (bytearray, memoryview, mmap.mmap)):
+        return (c_ubyte * len(buffer)).from_buffer(buffer)
+    else:
+        return ctypes.cast(buffer, c_ubyte_p)
+
+
+def increment_inplace(buffer: MutableBuffer):
+    sodium_increment(as_ucstr(buffer), len(buffer))
+
+
+def random_bytes(size: int) -> bytes:
+    if not isinstance(size, int):
+        raise TypeError("Invalid size")
+    buf = io.BytesIO(bytes(size))
+    buf_view = buf.getbuffer()
+    assert len(buf_view) == size
+    randombytes_buf(buf=as_ucstr(buf_view), size=size)
+    del buf_view
+    return buf.getvalue()
+
+
+@dataclasses.dataclass(init=False)
+class PWHash:
+    __slots__ = ("opslimit", "memlimit", "alg", "salt")
+
+    OPSLIMIT_MIN: ClassVar[int] = Const.crypto_pwhash_OPSLIMIT_MIN
+    OPSLIMIT_INTERACTIVE: ClassVar[int] = (
+        Const.crypto_pwhash_OPSLIMIT_INTERACTIVE
+    )
+    OPSLIMIT_MODERATE: ClassVar[int] = Const.crypto_pwhash_OPSLIMIT_MODERATE
+    OPSLIMIT_SENSITIVE: ClassVar[int] = Const.crypto_pwhash_OPSLIMIT_SENSITIVE
+    OPSLIMIT_MAX: ClassVar[int] = Const.crypto_pwhash_OPSLIMIT_MAX
+
+    MEMLIMIT_MIN: ClassVar[int] = Const.crypto_pwhash_MEMLIMIT_MIN
+    MEMLIMIT_INTERACTIVE: ClassVar[int] = (
+        Const.crypto_pwhash_MEMLIMIT_INTERACTIVE
+    )
+    MEMLIMIT_MODERATE: ClassVar[int] = Const.crypto_pwhash_MEMLIMIT_MODERATE
+    MEMLIMIT_SENSITIVE: ClassVar[int] = Const.crypto_pwhash_MEMLIMIT_SENSITIVE
+    MEMLIMIT_MAX: ClassVar[int] = Const.crypto_pwhash_MEMLIMIT_MAX
+
+    ALG_ARGON2ID13: ClassVar[int] = Const.crypto_pwhash_ALG_ARGON2ID13
+
+    opslimit: int
+    memlimit: int
+    alg: int
+
+    salt: bytes
+
+    @staticmethod
+    def _range_check(value, lower, upper, name: str) -> None:
+        if not (lower <= value <= upper):
+            raise ValueError(
+                f"Invalid {name}, must be in the range [{lower}, {upper})"
+            )
+
+    def __init__(
+        self,
+        salt: Union[bytes, bytearray, memoryview, None] = None,
+        opslimit=OPSLIMIT_MODERATE,
+        memlimit=MEMLIMIT_MODERATE,
+    ):
+        if salt is None:
+            salt: bytes = random_bytes(Const.crypto_pwhash_SALTBYTES)
+        elif not isinstance(salt, (bytes, bytearray, memoryview)):
+            raise TypeError(
+                "Invalid type for salt,"
+                " expected bytes, bytearray, or memoryview;"
+                f" got {type(salt).__name__}"
+            )
+        if len(salt) != Const.crypto_pwhash_SALTBYTES:
+            raise ValueError(
+                "Invalid salt size,"
+                f" {len(salt)} != {Const.crypto_pwhash_SALTBYTES}"
+            )
+        self._range_check(
+            opslimit,
+            Const.crypto_pwhash_OPSLIMIT_MIN,
+            Const.crypto_pwhash_OPSLIMIT_MAX,
+            "opslimit",
+        )
+        self._range_check(
+            memlimit,
+            Const.crypto_pwhash_MEMLIMIT_MIN,
+            Const.crypto_pwhash_MEMLIMIT_MAX,
+            "memlimit",
+        )
+        self.opslimit = opslimit
+        self.memlimit = memlimit
+        self.alg = Const.crypto_pwhash_ALG_ARGON2ID13
+        self.salt = salt
+
+    def hash(
+        self,
+        passwd: bytes,
+        output_size: int = Const.crypto_stream_salsa20_KEYBYTES,
+    ) -> bytes:
+        if not isinstance(passwd, bytes):
+            raise TypeError(
+                "Invalid type for password,"
+                f" expected bytes, got {type(passwd).__name__}"
+            )
+        if not isinstance(output_size, int):
+            raise TypeError(
+                "Invalid type for output_size"
+                f" expected int, got {type(output_size).__name__}"
+            )
+        self._range_check(
+            len(passwd),
+            Const.crypto_pwhash_PASSWD_MIN,
+            Const.crypto_pwhash_PASSWD_MAX,
+            "password length",
+        )
+        self._range_check(
+            output_size,
+            Const.crypto_pwhash_BYTES_MIN,
+            Const.crypto_pwhash_BYTES_MAX,
+            "output length",
+        )
+        buf = io.BytesIO(bytes(output_size))
+        buf_view = buf.getbuffer()
+        assert len(buf_view) == output_size
+        crypto_pwhash(
+            out=as_ucstr(buf_view),
+            outlen=output_size,
+            passwd=passwd,
+            passwdlen=len(passwd),
+            salt=as_ucstr(self.salt),
+            opslimit=self.opslimit,
+            memlimit=self.memlimit,
+            alg=self.alg,
+        )
+        del buf_view
+        return buf.getvalue()
+
+
+def poly1305(out: MutableBuffer, in_buf: Buffer, key: Buffer):
+    if len(out) != Const.crypto_onetimeauth_poly1305_BYTES:
+        raise ValueError("Invalid output buffer size for onetimeauth")
+    if len(key) < Const.crypto_onetimeauth_poly1305_KEYBYTES:
+        # The key can be larger, but only the prefix will be used
+        raise ValueError("Invalid key buffer size for onetimeauth")
+    return crypto_onetimeauth_poly1305(
+        out=as_ucstr(out),
+        in_buf=as_ucstr(in_buf),
+        inlen=len(in_buf),
+        k=as_ucstr(key),
+    )
+
+
+def salsa20_subkey(
+    nonce: Buffer, key: Buffer, out: Optional[MutableBuffer] = None
+) -> Optional[MutableBuffer]:
+    if out is None:
+        out = bytearray(Const.crypto_stream_salsa20_KEYBYTES)
+    elif len(out) != Const.crypto_stream_salsa20_KEYBYTES:
+        raise ValueError("Invalid key buffer size for hsalsa20")
+    crypto_core_hsalsa20(
+        out=as_ucstr(out), in_buf=as_ucstr(nonce), k=as_ucstr(key)
+    )
+    return out
+
+
+def salsa20_inplace(
+    buffer: MutableBuffer, nonce: Buffer, key: Buffer, block: int = 0
+):
+    if len(key) != Const.crypto_stream_salsa20_KEYBYTES:
+        raise ValueError(
+            "Invalid key buffer size for crypto_stream_salsa20_xor_ic"
+        )
+    if len(nonce) != Const.crypto_stream_salsa20_NONCEBYTES:
+        raise ValueError(
+            "Invalid nonce buffer size for crypto_stream_salsa20_xor_ic"
+        )
+    if block < 0:
+        raise ValueError("Invalid block index")
+
+    buf = as_ucstr(buffer)
+    return crypto_stream_salsa20_xor_ic(
+        c=buf,
+        m=buf,
+        mlen=len(buffer),
+        n=as_ucstr(nonce),
+        ic=block,
+        k=as_ucstr(key),
+    )
+
+
+def secretbox_inplace_open_detached_noverify(
+    buffer: MutableBuffer,
+    mac_out: MutableBuffer,
+    nonce: Buffer,
+    key: Buffer,
+):
+    # Based on crypto_secretbox_open_detached from:
+    # https://github.com/jedisct1/libsodium/blob/master/src/libsodium/crypto_secretbox/crypto_secretbox_easy.c
+    # But outputs the MAC instead of verifying it
+    block_0 = bytearray(SALSA20_BLOCK_SIZE)
+    subkey: bytearray = salsa20_subkey(nonce, key)
+    subnonce: Buffer = nonce[16:]
+
+    zero_bytes: int = libnacl.crypto_secretbox_ZEROBYTES
+    c_len = len(buffer)
+    m_len_0 = min(c_len, SALSA20_BLOCK_SIZE - zero_bytes)
+    with memoryview(buffer) as buf_mv:
+        block_0[zero_bytes : zero_bytes + m_len_0] = buf_mv[:m_len_0]
+        salsa20_inplace(block_0, subnonce, subkey)
+        poly1305(mac_out, buf_mv, block_0)
+        with memoryview(block_0) as block_mv:
+            buf_mv[:m_len_0] = block_mv[zero_bytes:]
+        sodium_memzero(as_ucstr(block_0), len(block_0))
+        if c_len > m_len_0:
+            salsa20_inplace(buf_mv[m_len_0:], subnonce, subkey, block=1)
+    sodium_memzero(as_ucstr(subkey), len(subkey))
+
+
+class AsymmetricParams(NamedTuple):
+    pk: bytes
+    sk: bytes
+    nonce: bytes
+    mac: Buffer
+
+    @classmethod
+    def random(cls):
+        pk, sk = libnacl.crypto_box_keypair()
+        nonce = libnacl.randombytes(libnacl.crypto_box_NONCEBYTES)
+        mac = bytearray(libnacl.crypto_box_MACBYTES)
+        return cls(pk, sk, nonce, mac)
+
+
+class SymmetricParams(NamedTuple):
+    k: bytes
+    nonce: bytes
+    mac: Buffer
+
+    @classmethod
+    def random(cls):
+        k = libnacl.randombytes(libnacl.crypto_secretbox_KEYBYTES)
+        nonce = libnacl.randombytes(libnacl.crypto_secretbox_NONCEBYTES)
+        mac = bytearray(libnacl.crypto_secretbox_MACBYTES)
+        return cls(k, nonce, mac)
+
+
+class _EncryptionManager(AbstractContextManager):
+    key: Buffer
+    buffer: MutableBuffer
+    total_size: int
+    emit_mac: bool
+    _owns_buffer_view: bool
+
+    class INTENT(enum.Flag):
+        ENCRYPTION = 1
+        DECRYPTION = 2
+
+        @classmethod
+        def both(cls) -> "_EncryptionManager.INTENT":
+            return cls.ENCRYPTION | cls.DECRYPTION
+
+    intent: INTENT
+
+    __slots__ = (
+        "key",
+        "buffer",
+        "total_size",
+        "emit_mac",
+        "_owns_buffer_view",
+        "intent",
+    )
+
+    KEY_BYTES: ClassVar[int] = libnacl.crypto_secretbox_KEYBYTES
+    NONCE_BYTES: ClassVar[int] = libnacl.crypto_secretbox_NONCEBYTES
+    MAC_BYTES: ClassVar[int] = libnacl.crypto_secretbox_MACBYTES
+
+    def __init__(
+        self,
+        key: Buffer,
+        buffer: MutableBuffer,
+        emit_mac: bool,
+        intent: INTENT,
+    ):
+        if len(key) != self.KEY_BYTES:
+            raise ValueError(
+                f"Invalid key length: {len(key):d} != {self.KEY_BYTES}"
+            )
+
+        try:
+            with memoryview(buffer) as mv:
+                buffer_read_only = mv.readonly
+        except TypeError as e:
+            raise TypeError(
+                f"Invalid buffer type: {buffer.__class__.__name__}"
+            ) from e
+        if buffer_read_only:
+            raise TypeError(
+                "Immutable buffers cannot be used for"
+                " in-place encryption or decryption"
+            )
+
+        if not intent:
+            raise ValueError("Invalid intent")
+
+        self.key = key
+        if isinstance(buffer, memoryview):
+            self.buffer = buffer.cast("B")
+            self._owns_buffer_view = True
+        else:
+            self.buffer = buffer
+            self._owns_buffer_view = False
+        self.total_size = len(self.buffer)
+        self.emit_mac = emit_mac
+        self.intent = intent
+
+    def close(self) -> None:
+        if self._owns_buffer_view:
+            self.buffer.release()
+
+    def __exit__(self, __exc_type, __exc_value, __traceback):
+        self.close()
+
+    @staticmethod
+    def is_mutable(buffer: Buffer) -> bool:
+        return not (
+            isinstance(buffer, bytes)
+            or isinstance(buffer, memoryview)
+            and buffer.readonly
+        )
+
+    @staticmethod
+    def random_nonce() -> bytes:
+        return random_bytes(_EncryptionManager.NONCE_BYTES)
+
+    @staticmethod
+    def _empty_mac() -> bytearray:
+        return bytearray(_EncryptionManager.MAC_BYTES)
+
+    @staticmethod
+    def _check_nonce(nonce: Buffer) -> None:
+        if len(nonce) != _EncryptionManager.NONCE_BYTES:
+            raise ValueError(
+                f"Invalid nonce length: {len(nonce):d} !="
+                f" {_EncryptionManager.NONCE_BYTES}"
+            )
+
+    @property
+    def _decrypt_intent(self) -> bool:
+        return bool(self.intent & self.INTENT.DECRYPTION)
+
+    @property
+    def _encrypt_intent(self) -> bool:
+        return bool(self.intent & self.INTENT.ENCRYPTION)
+
+    def _check_mac(self, mac: Buffer) -> None:
+        if len(mac) != _EncryptionManager.MAC_BYTES:
+            raise ValueError(
+                "Invalid MAC length:"
+                f" {len(mac):d} != {_EncryptionManager.MAC_BYTES}"
+            )
+        mac_is_mutable: bool = _EncryptionManager.is_mutable(mac)
+        if not mac_is_mutable:
+            if self._encrypt_intent:
+                raise TypeError(
+                    f"Immutable MAC (type: {mac.__class__.__name__})"
+                    " cannot be used for encryption"
+                )
+            if self.emit_mac:
+                raise TypeError(
+                    f"Immutable MAC (type: {mac.__class__.__name__})"
+                    " cannot be used for decryption with delayed"
+                    " verification"
+                )
+
+    def _inplace_encrypt(
+        self,
+        buffer: MutableBuffer,
+        mac: MutableBuffer,
+        nonce: Buffer,
+        release_on_exc: bool = False,
+    ) -> int:
+        if not self.is_mutable(mac):
+            raise TypeError(
+                f"Immutable MAC (type: {mac.__class__.__name__})"
+                " cannot be used for encryption"
+            )
+
+        ret = len(buffer)
+        buf = as_ucstr(buffer)
+        try:
+            crypto_secretbox_detached(
+                c=buf,
+                mac=as_ucstr(mac),
+                m=buf,
+                mlen=len(buffer),
+                n=as_ucstr(nonce),
+                k=as_ucstr(self.key),
+            )
+        except BaseException:
+            if release_on_exc and isinstance(buffer, memoryview):
+                buffer.release()
+            del buffer
+            raise
+        finally:
+            del buf
+        return ret
+
+    def _inplace_decrypt(
+        self,
+        buffer: MutableBuffer,
+        mac: Buffer,
+        nonce: Buffer,
+        release_on_exc: bool = False,
+    ) -> int:
+        ret = len(buffer)
+        try:
+            if not self.emit_mac:
+                buf = as_ucstr(buffer)
+                try:
+                    crypto_secretbox_open_detached(
+                        m=buf,
+                        c=buf,
+                        mac=as_ucstr(mac),
+                        clen=len(buffer),
+                        n=as_ucstr(nonce),
+                        k=as_ucstr(self.key),
+                    )
+                finally:
+                    del buf
+            else:
+                secretbox_inplace_open_detached_noverify(
+                    buffer, mac, nonce, self.key
+                )
+        except BaseException:
+            if release_on_exc and isinstance(buffer, memoryview):
+                buffer.release()
+            del buffer
+            raise
+        return ret
+
+
+class SequentialEncryption(_EncryptionManager):
+    nonce: Buffer
+    mac: Buffer
+
+    __slots__ = ("nonce", "mac")
+
+    def __init__(
+        self,
+        key: Buffer,
+        buffer: MutableBuffer,
+        nonce: Optional[Buffer] = None,
+        mac: Optional[Buffer] = None,
+        *,
+        intent: "SequentialEncryption.INTENT" = (
+            _EncryptionManager.INTENT.both()
+        ),
+    ):
+        super().__init__(key, buffer, False, intent)
+        decrypt_only: bool = self._decrypt_intent and not self._encrypt_intent
+        if nonce is None:
+            if decrypt_only:
+                raise TypeError("Nonce cannot be None for decryption")
+            nonce = self.random_nonce()
+        self.nonce = nonce
+        if mac is None:
+            if decrypt_only:
+                raise TypeError("MAC cannot be None for decryption")
+            mac = self._empty_mac()
+        self.mac = mac
+
+        self._check_nonce(nonce)
+        self._check_mac(mac)
+
+    def encrypt(self) -> None:
+        self._inplace_encrypt(self.buffer, self.mac, self.nonce)
+
+    def decrypt(self) -> None:
+        self._inplace_decrypt(self.buffer, self.mac, self.nonce)
+
+
+class ChunkedEncryption(_EncryptionManager):
+    chunk_size: int
+    nonces: Tuple[Buffer, ...]
+    macs: Tuple[Buffer, ...]
+    executor: ThreadPoolExecutor
+    _owns_executor: bool
+    num_chunks: int
+    remainder: int
+
+    __slots__ = (
+        "chunk_size",
+        "nonces",
+        "macs",
+        "executor",
+        "_owns_executor",
+        "num_chunks",
+        "remainder",
+    )
+
+    @staticmethod
+    def sequential_nonces(
+        initial_nonce: Buffer, count: int
+    ) -> Iterator[Buffer]:
+        if count < 0:
+            raise ValueError(f"Invalid nonce count: {count}")
+        elif count == 0:
+            return
+        elif count == 1 and isinstance(initial_nonce, bytes):
+            yield initial_nonce
+            return
+        nonce = io.BytesIO(initial_nonce)
+        del initial_nonce
+        yield nonce.getvalue()
+        for _ in range(1, count):
+            increment_inplace(nonce.getbuffer())
+            yield nonce.getvalue()
+
+    def __init__(
+        self,
+        key: Buffer,
+        buffer: MutableBuffer,
+        chunk_size: int,
+        nonces: Optional[Iterable[Buffer]] = None,
+        macs: Optional[Iterable[Buffer]] = None,
+        executor: Optional[ThreadPoolExecutor] = None,
+        num_threads: Optional[int] = None,
+        *,
+        automatic_verification: bool = True,
+        intent: "ChunkedEncryption.INTENT" = _EncryptionManager.INTENT.both(),
+    ):
+        super().__init__(
+            key=key,
+            buffer=buffer,
+            emit_mac=not automatic_verification,
+            intent=intent,
+        )
+        self.chunk_size = chunk_size
+        self.remainder = self.total_size % chunk_size
+        self.num_chunks = (self.total_size // chunk_size) + (self.remainder > 0)
+
+        decrypt_only: bool = self._decrypt_intent and not self._encrypt_intent
+        if nonces is None:
+            if decrypt_only:
+                raise TypeError("Nonces cannot be None for decryption")
+            initial_nonce = self.random_nonce()
+            self.nonces = tuple(
+                self.sequential_nonces(initial_nonce, self.num_chunks)
+            )
+        else:
+            self.nonces = tuple(nonces)
+        if macs is None:
+            if decrypt_only:
+                raise TypeError("MACs cannot be None for decryption")
+            self.macs = tuple(self._empty_mac() for _ in range(self.num_chunks))
+        else:
+            self.macs = tuple(macs)
+
+        nonce_count = len(self.nonces)
+        mac_count = len(self.macs)
+
+        if nonce_count != self.num_chunks:
+            raise ValueError(
+                f"Number of nonces provided ({nonce_count:d}) does not match"
+                f" the number of chunks ({self.num_chunks:d})"
+            )
+        elif mac_count != self.num_chunks:
+            raise ValueError(
+                f"Number of MACs provided ({mac_count:d}) does not match"
+                f" the number of chunks ({self.num_chunks:d})"
+            )
+
+        for nonce in self.nonces:
+            self._check_nonce(nonce)
+        for mac in self.macs:
+            self._check_mac(mac)
+
+        duplicate_nonce_count = nonce_count - len(set(self.nonces))
+        if duplicate_nonce_count != 0:
+            raise ValueError(
+                f"Nonces must be unique ({duplicate_nonce_count} duplicates)"
+            )
+
+        if executor is not None:
+            self._owns_executor = False
+            if num_threads is not None:
+                raise ValueError("Cannot specify both executor and num_threads")
+            self.executor = executor
+        else:
+            self._owns_executor = True
+            if num_threads is None:
+                num_threads = effective_cpu_count()
+            self.executor = ThreadPoolExecutor(
+                max_workers=min(num_threads, self.num_chunks),
+                thread_name_prefix="ChunkedEncryption",
+            )
+
+    def close(self) -> None:
+        super().close()
+        if self._owns_executor:
+            self.executor.shutdown(wait=False)
+
+    def _check_chunk_index(self, chunk_index: int) -> None:
+        if not isinstance(chunk_index, int):
+            raise TypeError("chunk_index must be an int")
+        if not (0 <= chunk_index < self.num_chunks):
+            raise IndexError(f"chunk_index {chunk_index:d} is out of bounds")
+
+    def _chunk_bounds(self, chunk_index: int) -> slice:
+        self._check_chunk_index(chunk_index)
+        begin = chunk_index * self.chunk_size
+        if chunk_index == self.num_chunks - 1:
+            end = self.total_size
+        else:
+            end = (chunk_index + 1) * self.chunk_size
+        return slice(begin, end)
+
+    def chunk_view(self, chunk_index: int) -> memoryview:
+        bounds = self._chunk_bounds(chunk_index)
+        with memoryview(self.buffer) as mv:
+            return mv[bounds]
+
+    def concatenated_macs(self) -> bytearray:
+        return bytearray().join(self.macs)
+
+    def coalesce_macs(self, nonce: Buffer) -> bytearray:
+        if len(nonce) != self.NONCE_BYTES:
+            raise ValueError(
+                f"Invalid nonce length: {len(nonce):d} != {self.NONCE_BYTES}"
+            )
+        final_mac = bytearray(self.MAC_BYTES)
+        self._inplace_encrypt(self.concatenated_macs(), final_mac, nonce)
+        return final_mac
+
+    def _transform_chunk(
+        self, transform_func, chunk_index: int
+    ) -> concurrent.futures.Future:
+        view = self.chunk_view(chunk_index)
+        mac = self.macs[chunk_index]
+        nonce = self.nonces[chunk_index]
+        future: concurrent.futures.Future = self.executor.submit(
+            transform_func, view, mac, nonce, True
+        )
+        future.add_done_callback(lambda _: view.release())
+        return future
+
+    def _transform_all(
+        self, func, wait: bool, timeout: Optional[float]
+    ) -> Iterable[concurrent.futures.Future]:
+        if not wait and timeout is not None:
+            raise ValueError("Cannot specify a timeout if wait=False")
+        futures = []
+        for i in range(self.num_chunks):
+            futures.append(func(i))
+        if wait:
+            self.wait_or_raise(futures, timeout)
+        return futures
+
+    @staticmethod
+    def wait_or_raise(
+        futures: Iterable[concurrent.futures.Future],
+        timeout: Optional[float],
+        return_when: str = concurrent.futures.FIRST_EXCEPTION,
+    ) -> None:
+        fs = concurrent.futures.wait(
+            futures,
+            timeout=timeout,
+            return_when=return_when,
+        )
+        del futures
+        raised = tuple(f for f in fs.done if f.exception() is not None)
+        if raised:
+            # Cancel other futures
+            for f in fs.not_done:
+                f.cancel()
+
+            # Wait for cancelled futures to finish to ensure
+            # that all buffers are released
+
+            # noinspection PyTypeChecker
+            cancel_timeout = None if timeout is None else timeout / 2
+            fs = concurrent.futures.wait(
+                fs.not_done,
+                timeout=cancel_timeout,
+                return_when=concurrent.futures.ALL_COMPLETED,
+            ).not_done
+
+            try:
+                for f in raised:
+                    f.result()  # Raise exceptions from threads
+                for f in fs:
+                    f.result(0)  # Raise exceptions from timeouts
+            finally:
+                del fs, raised
+        elif fs.not_done:
+            for f in fs.not_done:
+                f.result(0)
+
+    def encrypt_chunk(self, chunk_index: int) -> concurrent.futures.Future:
+        return self._transform_chunk(self._inplace_encrypt, chunk_index)
+
+    def encrypt_all(
+        self, wait: bool = False, timeout: Optional[float] = None
+    ) -> Iterable[concurrent.futures.Future]:
+        return self._transform_all(self.encrypt_chunk, wait, timeout)
+
+    def decrypt_chunk(self, chunk_index: int) -> concurrent.futures.Future:
+        return self._transform_chunk(self._inplace_decrypt, chunk_index)
+
+    def decrypt_all(
+        self, wait: bool = False, timeout: Optional[float] = None
+    ) -> Iterable[concurrent.futures.Future]:
+        return self._transform_all(self.decrypt_chunk, wait, timeout)
diff --git a/tensorizer/_crypt/_exceptions.py b/tensorizer/_crypt/_exceptions.py
new file mode 100644
index 00000000..4995882d
--- /dev/null
+++ b/tensorizer/_crypt/_exceptions.py
@@ -0,0 +1,2 @@
+class CryptographyError(Exception):
+    pass
diff --git a/tensorizer/_crypt_info.py b/tensorizer/_crypt_info.py
new file mode 100644
index 00000000..4aa84d96
--- /dev/null
+++ b/tensorizer/_crypt_info.py
@@ -0,0 +1,361 @@
+import abc
+import dataclasses
+import io
+import struct
+import typing
+import weakref
+from functools import partial
+from typing import ClassVar, List, Optional, Sequence, Union
+
+from tensorizer._internal_utils import _unpack_memoryview_from, _variable_read
+
+
+class CryptInfoChunk(abc.ABC):
+    _chunk_types: ClassVar[
+        typing.MutableMapping[int, typing.Type["CryptInfoChunk"]]
+    ] = weakref.WeakValueDictionary()
+    chunk_type: ClassVar[int]
+    _length_segment: ClassVar[struct.Struct] = struct.Struct("<Q")
+    _chunk_type_segment: ClassVar[struct.Struct] = struct.Struct("<H")
+
+    __slots__ = ()
+
+    @classmethod
+    @abc.abstractmethod
+    def unpack_from(cls, buffer, offset: int = 0) -> "CryptInfoChunk":
+        chunk_type = CryptInfoChunk._chunk_type_segment.unpack_from(
+            buffer, offset
+        )[0]
+        return CryptInfoChunk._chunk_types[chunk_type].unpack_from(
+            buffer, offset + CryptInfoChunk._chunk_type_segment.size
+        )
+
+    @abc.abstractmethod
+    def pack_into(self, buffer, offset: int = 0) -> int:
+        CryptInfoChunk._chunk_type_segment.pack_into(
+            buffer, offset, self.chunk_type
+        )
+        return offset + CryptInfoChunk._chunk_type_segment.size
+
+    def pack(self) -> bytes:
+        buffer = io.BytesIO(bytes(self.size))
+        self.pack_into(buffer.getbuffer(), 0)
+        return buffer.getvalue()
+
+    def sized_pack(self) -> bytes:
+        buffer = io.BytesIO(bytes(self.sized_size))
+        self.sized_pack_into(buffer.getbuffer(), 0)
+        return buffer.getvalue()
+
+    def sized_pack_into(self, buffer, offset: int = 0) -> int:
+        length_offset = offset
+        offset += CryptInfoChunk._length_segment.size
+        ret = self.pack_into(buffer, offset)
+        CryptInfoChunk._length_segment.pack_into(
+            buffer, length_offset, ret - offset
+        )
+        return ret
+
+    @property
+    def sized_size(self) -> int:
+        return self.size + CryptInfoChunk._length_segment.size
+
+    @property
+    @abc.abstractmethod
+    def size(self) -> int:
+        return CryptInfoChunk._chunk_type_segment.size
+
+    # noinspection PyMethodOverriding
+    def __init_subclass__(
+        cls, /, *, chunk_type: Optional[int] = None, **kwargs
+    ):
+        super().__init_subclass__(**kwargs)
+        if chunk_type is not None:
+            cls.chunk_type = chunk_type
+            CryptInfoChunk._chunk_types[chunk_type] = cls
+
+
+class KeyDerivationChunk(CryptInfoChunk, abc.ABC, chunk_type=1):
+    _derivation_methods: ClassVar[
+        typing.MutableMapping[int, typing.Type["KeyDerivationChunk"]]
+    ] = weakref.WeakValueDictionary()
+    derivation_method: ClassVar[int]
+    _derivation_method_segment: ClassVar[struct.Struct] = struct.Struct("<H")
+
+    __slots__ = ()
+
+    # noinspection PyMethodOverriding
+    def __init_subclass__(
+        cls, /, *, derivation_method: Optional[int] = None, **kwargs
+    ):
+        super().__init_subclass__(**kwargs)
+        if derivation_method is not None:
+            cls.derivation_method = derivation_method
+            KeyDerivationChunk._derivation_methods[derivation_method] = cls
+
+    @classmethod
+    @abc.abstractmethod
+    def unpack_from(cls, buffer, offset: int = 0) -> "KeyDerivationChunk":
+        derivation_method = (
+            KeyDerivationChunk._derivation_method_segment.unpack_from(
+                buffer, offset
+            )[0]
+        )
+        return KeyDerivationChunk._derivation_methods[
+            derivation_method
+        ].unpack_from(
+            buffer, offset + KeyDerivationChunk._derivation_method_segment.size
+        )
+
+    @abc.abstractmethod
+    def pack_into(self, buffer, offset: int = 0) -> int:
+        offset = super().pack_into(buffer, offset)
+        KeyDerivationChunk._derivation_method_segment.pack_into(
+            buffer, offset, self.derivation_method
+        )
+        return offset + KeyDerivationChunk._derivation_method_segment.size
+
+    @property
+    @abc.abstractmethod
+    def size(self) -> int:
+        return KeyDerivationChunk._derivation_method_segment.size + super().size
+
+
+@dataclasses.dataclass(frozen=True)
+class PWHashKeyDerivationChunk(KeyDerivationChunk, derivation_method=1):
+    opslimit: int
+    memlimit: int
+    alg: int
+    salt: Union[bytes, bytearray, memoryview]
+
+    __slots__ = ("opslimit", "memlimit", "alg", "salt")
+
+    _algorithm_segment: ClassVar[struct.Struct] = struct.Struct(
+        "<"  # Little-endian
+        "Q"  # Opslimit (unsigned long long)
+        "Q"  # Memlimit (size_t)
+        "i"  # Algorithm identifier (int)
+    )
+    _salt_segment_template: ClassVar[str] = (
+        "<H{salt_len:d}s"  # Salt length and bytes
+    )
+    read_salt = partial(_variable_read, length_fmt="H", data_fmt="s")
+
+    @property
+    def _salt_segment(self):
+        return struct.Struct(
+            self._salt_segment_template.format(salt_len=len(self.salt))
+        )
+
+    @classmethod
+    def unpack_from(cls, buffer, offset: int = 0) -> "PWHashKeyDerivationChunk":
+        opslimit, memlimit, alg = cls._algorithm_segment.unpack_from(
+            buffer, offset
+        )
+        offset += cls._algorithm_segment.size
+        salt = cls.read_salt(buffer, offset)[0]
+        return cls(opslimit=opslimit, memlimit=memlimit, alg=alg, salt=salt)
+
+    def pack_into(self, buffer, offset: int = 0) -> int:
+        offset = super().pack_into(buffer, offset)
+        self._algorithm_segment.pack_into(
+            buffer, offset, self.opslimit, self.memlimit, self.alg
+        )
+        offset += self._algorithm_segment.size
+        salt_segment = self._salt_segment
+        salt_segment.pack_into(buffer, offset, len(self.salt), self.salt)
+        offset += salt_segment.size
+        return offset
+
+    @property
+    def size(self) -> int:
+        return self._algorithm_segment.size + 2 + len(self.salt) + super().size
+
+
+@dataclasses.dataclass
+class XSalsa20ParallelChunk(CryptInfoChunk, chunk_type=2):
+    chunk_size: int
+    nonce: Union[bytes, bytearray, memoryview]
+    num_macs: int = dataclasses.field(init=False)
+    macs: Sequence[Union[bytes, bytearray, memoryview]]
+
+    __slots__ = ("chunk_size", "nonce", "macs", "__dict__")
+
+    NONCE_BYTES: ClassVar[int] = 24
+    MAC_BYTES: ClassVar[int] = 16
+    CHUNK_QUANTUM: ClassVar[int] = 64
+    MINIMUM_CHUNK_SIZE: ClassVar[int] = 1024
+
+    _header_segment: ClassVar[struct.Struct] = struct.Struct(
+        "<"  # Little-endian
+        "Q"  # Chunk size
+        f"{NONCE_BYTES:d}s"  # Initial nonce
+        "Q"  # Number of MACs
+    )
+
+    _mac_segment: ClassVar[struct.Struct] = struct.Struct(f"<{MAC_BYTES:d}s")
+
+    def __post_init__(self):
+        if len(self.nonce) != self.NONCE_BYTES:
+            raise ValueError("Invalid nonce size")
+        if not (
+            isinstance(self.chunk_size, int)
+            and (self.chunk_size % self.CHUNK_QUANTUM == 0)
+            and self.chunk_size >= self.MINIMUM_CHUNK_SIZE
+        ):
+            raise ValueError("Invalid chunk size")
+        self.num_macs = len(self.macs)
+        for mac in self.macs:
+            if len(mac) != self.MAC_BYTES:
+                raise ValueError("Invalid MAC size")
+
+    @classmethod
+    def unpack_from(cls, buffer, offset: int = 0) -> "XSalsa20ParallelChunk":
+        chunk_size, nonce, num_macs = (
+            XSalsa20ParallelChunk._header_segment.unpack_from(buffer, offset)
+        )
+        offset += XSalsa20ParallelChunk._header_segment.size
+        macs = []
+        for i in range(num_macs):
+            macs.append(
+                _unpack_memoryview_from(
+                    XSalsa20ParallelChunk._mac_segment.size, buffer, offset
+                )
+            )
+            offset += XSalsa20ParallelChunk._mac_segment.size
+        return cls(chunk_size, nonce, macs)
+
+    def pack_into(self, buffer, offset: int = 0) -> int:
+        offset = super().pack_into(buffer, offset)
+        XSalsa20ParallelChunk._header_segment.pack_into(
+            buffer, offset, self.chunk_size, self.nonce, self.num_macs
+        )
+        offset += XSalsa20ParallelChunk._header_segment.size
+        for mac in self.macs:
+            XSalsa20ParallelChunk._mac_segment.pack_into(buffer, offset, mac)
+            del mac
+            offset += XSalsa20ParallelChunk._mac_segment.size
+        return offset
+
+    @property
+    def size(self) -> int:
+        return (
+            XSalsa20ParallelChunk._header_segment.size
+            + XSalsa20ParallelChunk._mac_segment.size * self.num_macs
+            + super().size
+        )
+
+
+@dataclasses.dataclass
+class XSalsa20SequentialChunk(CryptInfoChunk, chunk_type=3):
+    nonce: Union[bytes, bytearray, memoryview]
+    mac: Union[bytes, bytearray, memoryview]
+
+    __slots__ = ("nonce", "mac")
+
+    NONCE_BYTES: ClassVar[int] = 24
+    MAC_BYTES: ClassVar[int] = 16
+
+    _contents_segment: ClassVar[struct.Struct] = struct.Struct(
+        "<"  # Little-endian
+        f"{NONCE_BYTES:d}s"  # Nonce
+        f"{MAC_BYTES:d}s"  # MAC
+    )
+
+    def __post_init__(self):
+        if len(self.nonce) != self.NONCE_BYTES:
+            raise ValueError("Invalid nonce size")
+        if len(self.mac) != self.MAC_BYTES:
+            raise ValueError("Invalid MAC size")
+
+    @classmethod
+    def unpack_from(cls, buffer, offset: int = 0) -> "XSalsa20SequentialChunk":
+        nonce, mac = XSalsa20SequentialChunk._contents_segment.unpack_from(
+            buffer, offset
+        )
+        return cls(nonce, mac)
+
+    def pack_into(self, buffer, offset: int = 0) -> int:
+        offset = super().pack_into(buffer, offset)
+        XSalsa20SequentialChunk._contents_segment.pack_into(
+            buffer, offset, self.nonce, self.mac
+        )
+        return offset + XSalsa20SequentialChunk._contents_segment.size
+
+    @property
+    def size(self) -> int:
+        return XSalsa20SequentialChunk._contents_segment.size + super().size
+
+
+@dataclasses.dataclass
+class CryptInfo:
+    num_chunks: int = dataclasses.field(init=False)
+    chunks: Sequence[CryptInfoChunk] = ()
+
+    _length_segment: ClassVar[struct.Struct] = struct.Struct(
+        "<Q"  # Little-endian segment length
+    )
+
+    _chunk_length_segment: ClassVar[struct.Struct] = _length_segment
+
+    _count_segment: ClassVar[struct.Struct] = struct.Struct(
+        "<H"  # Little-endian entry count
+    )
+
+    def __post_init__(self):
+        self.num_chunks = len(self.chunks)
+
+    @property
+    def sized_size(self) -> int:
+        return self._length_segment.size + self.size
+
+    @property
+    def size(self) -> int:
+        return self._count_segment.size + sum(c.sized_size for c in self.chunks)
+
+    def find_chunks(
+        self,
+        typ: Union[
+            typing.Type[CryptInfoChunk],
+            typing.Tuple[typing.Type[CryptInfoChunk], ...],
+        ],
+    ) -> Sequence[CryptInfoChunk]:
+        return tuple(c for c in self.chunks if isinstance(c, typ))
+
+    def pack_into(self, buffer, offset: int = 0) -> int:
+        CryptInfo._count_segment.pack_into(buffer, offset, self.num_chunks)
+        offset += CryptInfo._count_segment.size
+        for chunk in self.chunks:
+            offset = chunk.sized_pack_into(buffer, offset)
+        return offset
+
+    def sized_pack_into(self, buffer, offset: int = 0) -> int:
+        length_offset = offset
+        offset += CryptInfo._length_segment.size
+        ret = self.pack_into(buffer, offset)
+        CryptInfo._length_segment.pack_into(buffer, length_offset, ret - offset)
+        return ret
+
+    @classmethod
+    def unpack_from(cls, buffer, offset: int = 0) -> "CryptInfo":
+        num_chunks: int = CryptInfo._count_segment.unpack_from(buffer, offset)[
+            0
+        ]
+        offset += CryptInfo._count_segment.size
+        if num_chunks < 0:
+            raise ValueError(
+                "Invalid CryptInfo chunk count, cannot be negative"
+            )
+        chunks: List[CryptInfoChunk] = []
+        with memoryview(buffer) as mv:
+            for i in range(num_chunks):
+                chunk_size: int = CryptInfo._chunk_length_segment.unpack_from(
+                    buffer, offset
+                )[0]
+                offset += CryptInfo._chunk_length_segment.size
+                chunk_end: int = offset + chunk_size
+                with mv[offset:chunk_end] as chunk_mv:
+                    # Blocks out-of-bounds accesses
+                    chunks.append(CryptInfoChunk.unpack_from(chunk_mv))
+                offset = chunk_end
+        return cls(chunks)
diff --git a/tensorizer/_internal_utils.py b/tensorizer/_internal_utils.py
new file mode 100644
index 00000000..c4945572
--- /dev/null
+++ b/tensorizer/_internal_utils.py
@@ -0,0 +1,71 @@
+import dataclasses
+import struct
+import typing
+from typing import Tuple, Union
+
+_Buffer = Union[bytes, bytearray, memoryview]  # type: typing.TypeAlias
+
+
+@dataclasses.dataclass(init=False)
+class Chunked:
+    __slots__ = ("count", "total_size", "chunk_size", "remainder")
+    count: int
+    total_size: int
+    chunk_size: int
+    remainder: int
+
+    def __init__(self, total_size: int, chunk_size: int):
+        self.total_size = total_size
+        self.chunk_size = chunk_size
+        self.remainder = total_size % chunk_size
+        self.count = total_size // chunk_size + (self.remainder != 0)
+
+
+def _variable_read(
+    data: bytes, offset: int = 0, length_fmt: str = "B", data_fmt: str = "s"
+) -> Tuple[Union[memoryview, Tuple], int]:
+    """
+    Reads a variable-length field preceded by a length from a buffer.
+
+    Returns:
+        A tuple of the data read, and the offset in the buffer
+        following the end of the field.
+    """
+    assert length_fmt in ("B", "H", "I", "Q")
+    if length_fmt == "B":
+        length: int = data[offset]
+        offset += 1
+    else:
+        length_struct = struct.Struct("<" + length_fmt)
+        length: int = length_struct.unpack_from(data, offset)[0]
+        offset += length_struct.size
+    if data_fmt == "s":
+        # When the data is read as bytes, just return a memoryview
+        end = offset + length
+        return _unpack_memoryview_from(length, data, offset), end
+    else:
+        data_struct = struct.Struct(f"<{length:d}{data_fmt}")
+        data = data_struct.unpack_from(data, offset)
+        offset += data_struct.size
+        return data, offset
+
+
+def _unpack_memoryview_from(
+    length: int, buffer: _Buffer, offset: int
+) -> memoryview:
+    # Grabbing a memoryview with bounds checking.
+    # Bounds checking is normally provided by the struct module,
+    # but it can't return memoryviews.
+    with memoryview(buffer) as mv:
+        end = offset + length
+        view = mv[offset:end]
+        if len(view) < length:
+            view.release()
+            mv.release()
+            # Simulate a struct.error message for consistency
+            raise struct.error(
+                "unpack_from requires a buffer of at least"
+                f" {length:d} bytes for unpacking {length:d} bytes at offset"
+                f" {offset:d} (actual buffer size is {len(buffer):d})"
+            )
+        return view
diff --git a/tensorizer/_version.py b/tensorizer/_version.py
index e5e59e38..2614ce9d 100644
--- a/tensorizer/_version.py
+++ b/tensorizer/_version.py
@@ -1,1 +1,1 @@
-__version__ = "2.6.0"
+__version__ = "2.7.0"
diff --git a/tensorizer/serialization.py b/tensorizer/serialization.py
index 7fb7d3e8..9a99dec3 100644
--- a/tensorizer/serialization.py
+++ b/tensorizer/serialization.py
@@ -2,12 +2,14 @@
 # serialization.py                                                   Wes Brown
 # Fast torch module/model serialization/deserialization     (c) 2023 Coreweave
 ##############################################################################
-
+import abc
 import collections.abc
 import concurrent.futures
 import contextlib
 import ctypes
 import dataclasses
+import enum
+import functools
 import hashlib
 import io
 import itertools
@@ -19,6 +21,7 @@
 import threading
 import time
 import typing
+import weakref
 import zlib
 from collections import OrderedDict
 from enum import Enum
@@ -43,8 +46,15 @@
 import redis
 import torch
 
+import tensorizer._crypt as _crypt
+import tensorizer._crypt_info as _crypt_info
 import tensorizer.stream_io as stream_io
 import tensorizer.utils as utils
+from tensorizer._crypt._cgroup_cpu_count import (
+    effective_cpu_count as _effective_cpu_count,
+)
+from tensorizer._internal_utils import Chunked as _Chunked
+from tensorizer._internal_utils import _variable_read
 from tensorizer._NumpyTensor import _NumpyTensor
 from tensorizer.stream_io import CURLStreamFile
 
@@ -55,12 +65,51 @@
 
 lz4 = None
 
-__all__ = ["TensorSerializer", "TensorDeserializer", "TensorType"]
+__all__ = [
+    "TensorSerializer",
+    "TensorDeserializer",
+    "TensorType",
+    "CryptographyError",
+    "EncryptionParams",
+    "DecryptionParams",
+]
 
 # Setup logger
 logger = logging.getLogger(__name__)
 
 
+# Get CPU count
+cpu_count: int = _effective_cpu_count()
+
+
+class CryptographyError(_crypt.CryptographyError):
+    pass
+
+
+def _require_libsodium() -> None:
+    if not _crypt.available:
+        raise RuntimeError(
+            "libsodium shared object library not found or outdated."
+            " libsodium is a required dependency when using tensor encryption."
+            " Install an up-to-date version using the instructions at"
+            " https://doc.libsodium.org/installation or through"
+            ' a package manager (e.g. "apt-get install libsodium23")'
+        )
+
+
+def _requires_libsodium(func):
+    if _crypt.available:
+        return func
+    else:
+
+        @functools.wraps(func)
+        def wrapper(*args, **kwargs):
+            _require_libsodium()
+            return func(*args, **kwargs)
+
+        return wrapper
+
+
 # Whether the tensor is a parameter or a buffer on the model.
 class TensorType(Enum):
     PARAM = 0
@@ -71,13 +120,16 @@ class TensorType(Enum):
 # If tensors with "opaque" dtypes (those that are not supported by numpy) are
 # saved, then a tensorizer data version of 2 is required to (de)serialize the
 # file. Otherwise, the file is compatible with tensorizer data version 1
-TENSORIZER_VERSION = 2
+TENSORIZER_VERSION = 3
+OPAQUE_TENSORIZER_VERSION = 2
 NON_OPAQUE_TENSORIZER_VERSION = 1
 
 TENSORIZER_MAGIC = b"|TZR|"
 
 OPAQUE_DTYPE_SEP = "\0"
 
+_TIMEOUT: typing.Final[int] = 3600
+
 
 class HashType(Enum):
     CRC32 = 0
@@ -102,7 +154,7 @@ class TensorEntry:
         "data_offset",
         "data_length",
         "hashes",
-        "raw_headers",
+        "header_hashes",
     )
     name: str
     type: TensorType
@@ -112,7 +164,7 @@ class TensorEntry:
     data_offset: int
     data_length: int
     hashes: Optional[List[TensorHash]]
-    raw_headers: Optional[bytes]
+    header_hashes: Optional[Dict[HashType, Any]]
 
 
 @dataclasses.dataclass
@@ -132,6 +184,13 @@ class _FileHeader:
     tensor_size: int
     tensor_count: int
 
+    class InvalidVersionError(ValueError):
+        version: int
+
+        def __init__(self, *args, version: int):
+            super().__init__(*args)
+            self.version = version
+
     def to_bytes(self) -> bytes:
         return self.version_number_format.pack(
             self.version_number
@@ -145,12 +204,14 @@ def from_io(
             reader.read(cls.version_number_format.size)
         )[0]
         if version_number not in accepted_versions:
-            raise ValueError(
+            message = (
                 "Unsupported version: this data stream uses tensorizer"
                 f" data version {version_number}, which is not supported"
-                " in this release of tensorizer."
+                " in this release of tensorizer, or"
+                " for the serialization/deserialization features selected."
                 f"\nSupported data versions: {tuple(accepted_versions)}"
             )
+            raise cls.InvalidVersionError(message, version=version_number)
         data = reader.read(cls.format.size)
         if len(data) < cls.format.size:
             raise ValueError(
@@ -193,7 +254,7 @@ class _TensorHeaderSerializer:
         "B"  # Hash count (fixed for a particular tensorizer version)
     )
     hash_header_offset: int
-    hash_count: ClassVar[int] = 2
+    hash_count: int
 
     crc32_hash_segment: ClassVar[struct.Struct] = struct.Struct(
         "<"
@@ -202,6 +263,7 @@ class _TensorHeaderSerializer:
         "I"  # CRC32 hash value
     )
     crc32_hash_offset: int
+    has_crc32: bool
 
     sha256_hash_segment: ClassVar[struct.Struct] = struct.Struct(
         "<"
@@ -210,6 +272,10 @@ class _TensorHeaderSerializer:
         "32s"  # SHA256 hash value
     )
     sha256_hash_offset: int
+    has_sha256: bool
+
+    crypt_info: Optional[_crypt_info.CryptInfo]
+    crypt_info_offset: int
 
     data_length_segment: ClassVar[struct.Struct] = struct.Struct(
         "<q"  # Signed tensor data length
@@ -250,6 +316,9 @@ def __init__(
         shape: Sequence[int],
         data_length: int,
         file_offset: int,
+        include_crc32: bool = True,
+        include_sha256: bool = True,
+        crypt_info: Optional[_crypt_info.CryptInfo] = None,
     ):
         # Calculate the variable length segment
         name_len = len(name)
@@ -257,6 +326,11 @@ def __init__(
         # NB: shape_len is the number of dimensions,
         # not the encoded byte length
         shape_len = len(shape)
+        self.crypt_info = crypt_info
+        if crypt_info is None:
+            crypt_info_len = 0
+        else:
+            crypt_info_len = crypt_info.sized_size
         self.variable_length_segment = struct.Struct(
             self.variable_length_segment_template.format(
                 name_len=name_len,
@@ -264,6 +338,15 @@ def __init__(
                 shape_len=shape_len,
             )
         )
+        crc32_len = sha256_len = self.hash_count = 0
+        self.has_crc32 = include_crc32
+        self.has_sha256 = include_sha256
+        if include_crc32:
+            crc32_len = self.crc32_hash_segment.size
+            self.hash_count += 1
+        if include_sha256:
+            sha256_len = self.sha256_hash_segment.size
+            self.hash_count += 1
 
         # Calculate offsets
         (
@@ -271,6 +354,7 @@ def __init__(
             self.hash_header_offset,
             self.crc32_hash_offset,
             self.sha256_hash_offset,
+            self.crypt_info_offset,
             self.data_length_offset,
             self.data_offset,
         ) = itertools.accumulate(
@@ -278,8 +362,9 @@ def __init__(
                 self.start_segment.size,
                 self.variable_length_segment.size,
                 self.hash_header_segment.size,
-                self.crc32_hash_segment.size,
-                self.sha256_hash_segment.size,
+                crc32_len,
+                sha256_len,
+                crypt_info_len,
                 self.data_length_segment.size,
             )
         )
@@ -303,9 +388,9 @@ def __init__(
             shape_len,  # Tensor shape length
             *shape,  # Tensor shape I array
         )
-        self.hash_segment_size = (
-            self.data_length_offset - self.hash_header_offset - 2
-        )
+
+        after_hashes = self.crypt_info_offset
+        self.hash_segment_size = after_hashes - self.hash_header_offset - 2
         self.hash_header_segment.pack_into(
             self.buffer,
             self.hash_header_offset,
@@ -314,8 +399,12 @@ def __init__(
         )
 
         # Placeholders
-        self.add_crc32(0)
-        self.add_sha256(b"")
+        if include_crc32:
+            self.add_crc32(0)
+        if include_sha256:
+            self.add_sha256(b"")
+        if crypt_info is not None:
+            crypt_info.sized_pack_into(self.buffer, self.crypt_info_offset)
 
         self.data_length_segment.pack_into(
             self.buffer, self.data_length_offset, data_length
@@ -343,7 +432,33 @@ def __init__(
             data_length,  # Tensor length
         )
 
+    def _hashable_segment_views(self):
+        if self.crypt_info is None:
+            yield memoryview(self.buffer)
+        else:
+            yield memoryview(self.buffer)[: self.crypt_info_offset]
+            # Skip crypt_info
+            yield memoryview(self.buffer)[self.data_length_offset :]
+
+    def compute_crc32(self) -> int:
+        crc32 = 0
+        for view in self._hashable_segment_views():
+            with view:
+                crc32 = zlib.crc32(view, crc32)
+        return crc32
+
+    def compute_sha256(self):
+        sha256 = hashlib.sha256()
+        for view in self._hashable_segment_views():
+            with view:
+                sha256.update(view)
+        return sha256
+
     def add_crc32(self, value: int):
+        if not self.has_crc32:
+            raise ValueError(
+                "Cannot add CRC32 to header defined without a CRC32 field"
+            )
         self.crc32_hash_segment.pack_into(
             self.buffer,
             self.crc32_hash_offset,
@@ -353,6 +468,10 @@ def add_crc32(self, value: int):
         )
 
     def add_sha256(self, value: bytes):
+        if not self.has_sha256:
+            raise ValueError(
+                "Cannot add SHA256 to header defined without a SHA256 field"
+            )
         self.sha256_hash_segment.pack_into(
             self.buffer,
             self.sha256_hash_offset,
@@ -361,35 +480,9 @@ def add_sha256(self, value: bytes):
             value,  # Hash value
         )
 
-
-def _variable_read(
-    data: bytes, offset: int = 0, length_fmt: str = "B", data_fmt: str = "s"
-) -> Tuple[Union[memoryview, Tuple], int]:
-    """
-    Reads a variable-length field preceded by a length from a buffer.
-
-    Returns:
-        A tuple of the data read, and the offset in the buffer
-        following the end of the field.
-    """
-    assert length_fmt in ("B", "H", "I", "Q")
-    if length_fmt == "B":
-        length: int = data[offset]
-        offset += 1
-    else:
-        length_struct = struct.Struct("<" + length_fmt)
-        length: int = length_struct.unpack_from(data, offset)[0]
-        offset += length_struct.size
-    if data_fmt == "s":
-        # When the data is read as bytes, just return a memoryview
-        end = offset + length
-        with memoryview(data) as mv:
-            return mv[offset:end], end
-    else:
-        data_struct = struct.Struct(f"<{length:d}{data_fmt}")
-        data = data_struct.unpack_from(data, offset)
-        offset += data_struct.size
-        return data, offset
+    def update_crypt_info(self):
+        if self.crypt_info is not None:
+            self.crypt_info.sized_pack_into(self.buffer, self.crypt_info_offset)
 
 
 @dataclasses.dataclass(init=False)
@@ -401,8 +494,11 @@ class _TensorHeaderDeserializer:
     dtype: str
     shape: Tuple[int, ...]
     hashes: List[TensorHash]
+    crypt_info: Optional[_crypt_info.CryptInfo]
     data_length: int
 
+    _hashable_segments: Sequence[slice]
+
     header_len_segment: ClassVar[struct.Struct] = struct.Struct("<Q")
     tensor_info_segment: ClassVar[struct.Struct] = struct.Struct(
         "<HB"  # Module index, tensor type
@@ -412,12 +508,18 @@ class _TensorHeaderDeserializer:
     read_dtype = partial(_variable_read, length_fmt="B", data_fmt="s")
     read_shape = partial(_variable_read, length_fmt="B", data_fmt="I")
     read_hash_block = partial(_variable_read, length_fmt="H", data_fmt="s")
+    read_crypt_info_block = partial(
+        _variable_read, length_fmt="Q", data_fmt="s"
+    )
 
     data_length_segment: ClassVar[struct.Struct] = struct.Struct("<q")
 
     @classmethod
     def from_io(
-        cls, reader: io.BufferedIOBase, zero_hashes: bool = True
+        cls,
+        reader: io.BufferedIOBase,
+        zero_hashes: bool = True,
+        check_crypt_info: bool = False,
     ) -> Optional["_TensorHeaderDeserializer"]:
         # We read the entire header into memory rather than reading
         # it piecewise to avoid the overhead of many small reads,
@@ -431,9 +533,16 @@ def from_io(
         buffer[:offset] = header_len_bytes
         with memoryview(buffer) as mv:
             reader.readinto(mv[offset:])
-        return cls(buffer, zero_hashes=zero_hashes)
+        return cls(
+            buffer, zero_hashes=zero_hashes, check_crypt_info=check_crypt_info
+        )
 
-    def __init__(self, buffer: bytearray, zero_hashes: bool = True):
+    def __init__(
+        self,
+        buffer: bytearray,
+        zero_hashes: bool = True,
+        check_crypt_info: bool = False,
+    ):
         self.buffer = buffer
         offset = self.header_len_segment.size
         self.module_idx, tensor_type = self.tensor_info_segment.unpack_from(
@@ -462,12 +571,58 @@ def __init__(self, buffer: bytearray, zero_hashes: bool = True):
             if zero_hashes:
                 self._zero_hashes(hashes_slice)
 
+        if check_crypt_info:
+            crypt_info_start = offset
+            crypt_info_slice, offset = self.read_crypt_info_block(
+                buffer, offset
+            )
+            self._hashable_segments = (
+                slice(None, crypt_info_start),
+                slice(offset, None),
+            )
+            with crypt_info_slice:
+                self.crypt_info = _crypt_info.CryptInfo.unpack_from(
+                    crypt_info_slice
+                )
+        else:
+            self.crypt_info = None
+            self._hashable_segments = (slice(None, None),)
+
         # Finally, get the tensor data length.
         offset = len(buffer) - self.data_length_segment.size
         self.data_length = self.data_length_segment.unpack_from(buffer, offset)[
             0
         ]
 
+    def _hashable_segment_views(self):
+        for segment_slice in self._hashable_segments:
+            yield memoryview(self.buffer)[segment_slice]
+
+    def compute_crc32(self) -> int:
+        crc32 = 0
+        for view in self._hashable_segment_views():
+            with view:
+                crc32 = zlib.crc32(view, crc32)
+        return crc32
+
+    def compute_sha256(self):
+        sha256 = hashlib.sha256()
+        for view in self._hashable_segment_views():
+            with view:
+                sha256.update(view)
+        return sha256
+
+    def compute_hashes(self) -> Dict[HashType, Any]:
+        hashes = {}
+        for hash_type in self.hashes:
+            if hash_type.type in hashes:
+                continue
+            elif hash_type.type == HashType.CRC32:
+                hashes[hash_type.type] = self.compute_crc32()
+            elif hash_type.type == HashType.SHA256:
+                hashes[hash_type.type] = self.compute_sha256()
+        return hashes
+
     @staticmethod
     def _decode_hashes(b: memoryview) -> List[TensorHash]:
         """
@@ -591,7 +746,7 @@ def _read_entry(cls, buffer: bytes, offset: int) -> Tuple[TensorEntry, int]:
                 data_length=data_length,
                 # The following fields are only available in the per-tensor headers
                 hashes=None,
-                raw_headers=None,
+                header_hashes=None,
             ),
             offset,
         )
@@ -601,6 +756,573 @@ class HashMismatchError(Exception):
     pass
 
 
+@dataclasses.dataclass(init=False)
+class EncryptionParams:
+    """
+    Defines encryption parameters for a TensorSerializer.
+
+    There are three ways to use this class, mainly using its factory functions:
+
+    #. Using `EncryptionParams.random()`
+
+    This will generate a random encryption key.
+    This is the fastest and most secure option, but you must
+    save it somewhere to be able to use it for decryption later.
+
+    #. Using `EncryptionParams.from_string()`
+
+    This will generate a reproducible encryption key from an arbitrary string,
+    using the Argon2 (Argon2id, RFC 9106) password hashing algorithm.
+
+    The resulting key has resistance against brute-force attacks that attempt
+    to guess the input string, achieved by making each attempt
+    expensive to compute, both in CPU time and RAM usage.
+
+    The difficulty to compute the key may be adjusted using additional
+    parameters to ``from_string()``.
+    See `EncryptionParams.from_string()`'s documentation for more details.
+
+    #. Using `EncryptionParams(key=...)` directly
+
+    You can supply an exact key to use for encryption by directly invoking
+    the `EncryptionParams` constructor. This must be a `bytes` object of the
+    correct length to be used as an XSalsa20 cipher key (32 bytes).
+
+    This allows bringing your own key derivation algorithm or random key source,
+    but is more complicated and risky to use than the other options.
+    Do not use this with an insecure key.
+
+    Examples:
+
+        Using `EncryptionParams.from_string()` with
+        an environment variable::
+
+            source: str = os.getenv("SUPER_SECRET_STRONG_PASSWORD")
+            encryption_params = EncryptionParams.from_string(source)
+
+            # Use this to encrypt something:
+            serializer = TensorSerializer(
+                "model.tensors", encryption=encryption_params
+            )
+            serializer.write_module(...)
+            serializer.close()
+
+            # Then decrypt it again
+            decryption_params = DecryptionParams.from_string(source)
+            deserializer = TensorDeserializer(
+                "model.tensors", encryption=decryption_params
+            )
+            deserializer.load_into_module(...)
+            deserializer.close()
+
+
+        Using `EncryptionParams.random()`::
+
+            encryption_params = EncryptionParams.random()
+
+            # Use this to encrypt something:
+            serializer = TensorSerializer(
+                "model.tensors", encryption=encryption_params
+            )
+            serializer.write_module(...)
+            serializer.close()
+
+            # Then decrypt it again
+            key: bytes = encryption_params.key
+            decryption_params = DecryptionParams.from_key(key)
+            deserializer = TensorDeserializer(
+                "model.tensors", encryption=decryption_params
+            )
+            deserializer.load_into_module(...)
+            deserializer.close()
+    """
+
+    __slots__ = ("key", "_algorithm")
+
+    class _Algorithm(abc.ABC):
+        __slots__ = ()
+
+        @abc.abstractmethod
+        def chunk(self) -> _crypt_info.KeyDerivationChunk:
+            ...
+
+    @dataclasses.dataclass
+    class _FromStringPWHashAlgorithm(_Algorithm):
+        __slots__ = ("pwhash_params",)
+        pwhash_params: "_crypt.PWHash"
+
+        def chunk(self) -> _crypt_info.KeyDerivationChunk:
+            return _crypt_info.PWHashKeyDerivationChunk(
+                opslimit=self.pwhash_params.opslimit,
+                memlimit=self.pwhash_params.memlimit,
+                alg=self.pwhash_params.alg,
+                salt=self.pwhash_params.salt,
+            )
+
+    key: bytes
+    _algorithm: Optional[_Algorithm]
+
+    @_requires_libsodium
+    def __init__(self, key: bytes):
+        if not isinstance(key, (bytes, bytearray, memoryview)):
+            raise TypeError(
+                "Encryption key must be a cryptographically secure bytestring."
+                " To derive an encryption key from an arbitrary string,"
+                " use EncryptionParams.from_string()"
+            )
+
+        if len(key) != _crypt.ChunkedEncryption.KEY_BYTES:
+            raise ValueError(
+                "Invalid encryption key length,"
+                f" should be {_crypt.ChunkedEncryption.KEY_BYTES} bytes;"
+                f" got {len(key)} bytes instead."
+                " To generate a valid encryption key from any string"
+                " or bytes object,"
+                " use EncryptionParams.from_string()"
+            )
+        self.key = bytes(key)
+        self._algorithm = None
+
+    @classmethod
+    @_requires_libsodium
+    def random(cls) -> "EncryptionParams":
+        """
+        Generates a random encryption key with no associated source string.
+
+        This is the fastest and most secure option, but you must
+        save the resulting key (from ``EncryptionParams.key``)
+        somewhere to be able to use it for decryption later.
+
+        Returns:
+            An `EncryptionParams` instance to pass to a `TensorSerializer`.
+        """
+        return cls(_crypt.random_bytes(_crypt.ChunkedEncryption.KEY_BYTES))
+
+    @staticmethod
+    @_requires_libsodium
+    def _derive_salt(
+        salt: Union[str, bytes, bytearray, memoryview, None],
+        encoding,
+        fallback_size: int = 32,
+    ) -> bytes:
+        if salt is None:
+            return _crypt.random_bytes(fallback_size)
+        elif isinstance(salt, (bytes, bytearray, memoryview)):
+            return salt
+        elif isinstance(salt, str):
+            return salt.encode(encoding)
+        else:
+            raise TypeError("Invalid object type provided for salt")
+
+    @_requires_libsodium
+    def _crypt_info_chunk(self) -> Optional[_crypt_info.CryptInfoChunk]:
+        if self._algorithm is None:
+            return None
+        else:
+            return self._algorithm.chunk()
+
+    if _crypt.available:
+
+        class OpsLimit(enum.IntEnum):
+            """
+            For discussion on this parameter, see the libsodium documentation:
+            https://libsodium.gitbook.io/doc/password_hashing/default_phf#key-derivation
+
+            Summary quote:
+                opslimit represents the maximum amount of computations
+                to perform. Raising this number will make the function
+                require more CPU cycles to compute a key.
+
+            The preset levels are related to each other as follows:
+            `MIN` < `INTERACTIVE` < `MODERATE` < `SENSITIVE`
+            (`MIN` is easiest to compute, `SENSITIVE` is hardest).
+            """
+
+            MIN = _crypt.PWHash.OPSLIMIT_MIN
+            INTERACTIVE = _crypt.PWHash.OPSLIMIT_INTERACTIVE
+            MODERATE = _crypt.PWHash.OPSLIMIT_MODERATE
+            SENSITIVE = _crypt.PWHash.OPSLIMIT_SENSITIVE
+
+        class MemLimit(enum.IntEnum):
+            """
+            For discussion on this parameter, see the libsodium documentation:
+            https://libsodium.gitbook.io/doc/password_hashing/default_phf#key-derivation
+
+            Summary quote:
+                memlimit is the maximum amount of RAM in bytes
+                that the function will use.
+
+            The preset levels are related to each other as follows:
+            `MIN` < `INTERACTIVE` < `MODERATE` < `SENSITIVE`
+            (`MIN` is easiest to compute, `SENSITIVE` is hardest).
+            """
+
+            MIN = _crypt.PWHash.MEMLIMIT_MIN
+            INTERACTIVE = _crypt.PWHash.MEMLIMIT_INTERACTIVE
+            MODERATE = _crypt.PWHash.MEMLIMIT_MODERATE
+            SENSITIVE = _crypt.PWHash.MEMLIMIT_SENSITIVE
+
+    else:
+
+        class OpsLimit(enum.IntEnum):
+            def __getattribute__(self, item):
+                super().__getattribute__(self, item)
+                _require_libsodium()
+
+        class MemLimit(enum.IntEnum):
+            def __getattribute__(self, item):
+                super().__getattribute__(self, item)
+                _require_libsodium()
+
+    @property
+    def salt(self) -> bytes:
+        """
+        Cryptographic salt used for key derivation.
+        This is stored within a serialized model, and will be retrieved
+        automatically during deserialization, so it does not normally
+        need to be manually saved.
+
+        However, manually saving this value will allow recalculating an exact
+        binary key separately from the deserialization process.
+
+        Returns:
+            The cryptographic salt used for key derivation.
+        Raises:
+            ValueError: If no salt is being used for key derivation.
+        """
+        if isinstance(self._algorithm, self._FromStringPWHashAlgorithm):
+            return bytes(self._algorithm.pwhash_params.salt)
+        elif self._algorithm is None:
+            raise ValueError(
+                "An exact binary key is being used."
+                " Exact binary keys do not use key derivation algorithms,"
+                " and thus have no salt."
+            )
+        else:
+            raise ValueError(
+                "The key derivation algorithm in use does not use a salt."
+            )
+
+    @classmethod
+    @_requires_libsodium
+    def from_string(
+        cls,
+        source: Union[str, bytes],
+        opslimit: Union[OpsLimit, int, None] = None,
+        memlimit: Union[MemLimit, int, None] = None,
+        salt: Union[str, bytes, None] = None,
+        *,
+        encoding="utf-8",
+    ) -> "EncryptionParams":
+        """
+        Generates an encryption key from any string.
+
+        This method uses the Argon2 (Argon2id, RFC 9106) password hashing
+        algorithm to create a key from an input string.
+
+        The key has resistance against brute-force attacks that attempt
+        to guess the input string, achieved by making each attempt
+        expensive to compute, both in CPU time and RAM usage.
+
+        The computational difficulty can be increased or decreased
+        via the `opslimit` and `memlimit` parameters.
+        Higher computational difficulty gives more security
+        for weak input strings, but may impact performance.
+        The default setting is a "moderate" profile taken from ``libsodium``.
+
+        Presets (as well as minimum values) are available through the
+        `EncryptionParams.OpsLimit` and `EncryptionParams.MemLimit` enums.
+
+        Rough estimates of performance impact (on a 3.20 GHz processor)::
+
+            from tensorizer import EncryptionParams
+
+            OpsLimit = EncryptionParams.OpsLimit
+            MemLimit = EncryptionParams.MemLimit
+            s = "X" * 40
+
+            EncryptionParams.from_string(  # Takes about 0.05 ms, 8 KiB RAM
+                s, opslimit=OpsLimit.MIN, memlimit=MemLimit.MIN
+            )
+            EncryptionParams.from_string(  # Takes about 90 ms, 64 MiB RAM
+                s, opslimit=OpsLimit.INTERACTIVE, memlimit=MemLimit.INTERACTIVE
+            )
+            EncryptionParams.from_string(  # Takes about 500 ms, 256 MiB RAM
+                s, opslimit=OpsLimit.MODERATE, memlimit=MemLimit.MODERATE
+                # Default: equivalent to opslimit=None, memlimit=None
+            )
+            EncryptionParams.from_string(  # Takes about 3.0 seconds, 1 GiB RAM
+                s, opslimit=OpsLimit.SENSITIVE, memlimit=MemLimit.SENSITIVE
+            )
+
+        Performance Tuning:
+            If possible, use `EncryptionParams.random()` instead of this method,
+            and save the generated key to use for decryption.
+
+            If that is not possible, save the binary key generated during
+            `EncryptionParams.from_string()` (from the ``.key`` attribute),
+            and use that key for decryption (via `DecryptionParams.from_key()`)
+            to remove the cost of re-computing the key at deserialization time.
+
+            If that is not possible, use a strong input string.
+            For input strings that are already very strong and high-entropy,
+            where brute-force attacks on the input string are no more likely
+            to succeed than brute-force attacks on a 256-bit key itself,
+            (e.g. very long, randomly generated strings),
+            `opslimit` and `memlimit` may be tuned down to minimize
+            their performance impact.
+
+            If that is not possible, test different values of `opslimit`
+            and `memlimit` to determine an acceptable tradeoff between
+            performance and security for your use case.
+
+        See Also:
+            ``libsodium`` documentation for ``pwhash``,
+            the Argon2id implementation used in ``from_string()``:
+            https://libsodium.gitbook.io/doc/password_hashing/default_phf#key-derivation
+
+        See Also:
+            RFC 9106 for details on Argon2,
+            https://datatracker.ietf.org/doc/html/rfc9106
+
+        Args:
+            source: The source string from which to derive a key.
+            opslimit:
+                Difficulty of the key derivation algorithm on CPU resources.
+                For details, refer to the `libsodium documentation`_.
+                Can be provided as a preset ``EncryptionParams.OpsLimit`` value,
+                or a custom integer. If None (the default),
+                uses ``EncryptionParams.OpsLimit.MODERATE``.
+            memlimit:
+                Amount of RAM required by the key derivation algorithm.
+                For details, refer to the `libsodium documentation`_.
+                Can be provided as a preset ``EncryptionParams.MemLimit`` value,
+                or a custom integer. If None (the default),
+                uses ``EncryptionParams.MemLimit.MODERATE``.
+            salt: A non-secret cryptographic salt to be stored
+                in the serialized file.
+                If None (the default), a secure random salt is used.
+                This normally does not need to be chosen manually, however,
+                this can allow reproducing an exact binary key separately
+                from deserialization.
+            encoding: The encoding to use to convert `source` to ``bytes``
+                if provided as a ``str``. Defaults to UTF-8.
+
+        Returns:
+            An `EncryptionParams` instance to pass to a `TensorSerializer`.
+
+        .. _libsodium documentation: https://libsodium.gitbook.io/doc/password_hashing/default_phf#key-derivation
+        """
+        if not source:
+            raise ValueError("Source cannot be empty")
+        if isinstance(opslimit, cls.MemLimit) or not isinstance(
+            opslimit, (cls.OpsLimit, int, type(None))
+        ):
+            raise TypeError(
+                "opslimit parameter: expected EncryptionParams.OpsLimit,"
+                f" int, or None; {opslimit.__class__.__name__} found"
+            )
+        if isinstance(memlimit, cls.OpsLimit) or not isinstance(
+            memlimit, (cls.MemLimit, int, type(None))
+        ):
+            raise TypeError(
+                "memlimit parameter: expected EncryptionParams.MemLimit,"
+                f" int, or None; {memlimit.__class__.__name__} found"
+            )
+        if isinstance(source, str):
+            source = source.encode(encoding)
+        if opslimit is None:
+            opslimit = cls.OpsLimit.MODERATE
+        if memlimit is None:
+            memlimit = cls.MemLimit.MODERATE
+        pwhash_params = _crypt.PWHash(
+            salt=salt, opslimit=opslimit, memlimit=memlimit
+        )
+        encryption_params = cls(key=pwhash_params.hash(source))
+        encryption_params._algorithm = cls._FromStringPWHashAlgorithm(
+            pwhash_params=pwhash_params
+        )
+        return encryption_params
+
+
+@dataclasses.dataclass(init=False)
+class DecryptionParams:
+    """
+    Defines decryption parameters for a TensorDeserializer.
+
+    There are two ways to use this class, using its factory functions:
+
+    #. Using `DecryptionParams.from_string()`
+
+    This will decrypt tensors using the specified key string.
+    This may be used if `EncryptionParams.from_string()`
+    was used during encryption.
+
+    #. Using `DecryptionParams.from_key()`
+
+    This will decrypt tensors using an exact binary key.
+    This may always be used with the ``key`` from an `EncryptionParams` object,
+    regardless of whether the key was generated with
+    `EncryptionParams.from_string()` or `EncryptionParams.random()`.
+
+    Examples:
+
+        Using `DecryptionParams.from_string()` with
+        an environment variable::
+
+            source: str = os.getenv("SUPER_SECRET_STRONG_PASSWORD")
+            encryption_params = EncryptionParams.from_string(source)
+
+            # Use this to encrypt something:
+            serializer = TensorSerializer(
+                "model.tensors", encryption=encryption_params
+            )
+            serializer.write_module(...)
+            serializer.close()
+
+            # Then decrypt it again
+            decryption_params = DecryptionParams.from_string(source)
+            deserializer = TensorDeserializer(
+                "model.tensors", encryption=decryption_params
+            )
+            deserializer.load_into_module(...)
+            deserializer.close()
+
+
+        Using `DecryptionParams.from_key()`::
+
+            encryption_params = EncryptionParams.random()
+
+            # Use this to encrypt something:
+            serializer = TensorSerializer(
+                "model.tensors", encryption=encryption_params
+            )
+            serializer.write_module(...)
+            serializer.close()
+
+            # Then decrypt it again
+            key: bytes = encryption_params.key
+            decryption_params = DecryptionParams.from_key(key)
+            deserializer = TensorDeserializer(
+                "model.tensors", encryption=decryption_params
+            )
+            deserializer.load_into_module(...)
+            deserializer.close()
+    """
+
+    key: Optional[bytes]
+    source: Optional[bytes]
+
+    def __init__(self):
+        self.key = None
+        self.source = None
+
+    @classmethod
+    @_requires_libsodium
+    def from_string(
+        cls, source: Union[str, bytes], *, encoding="utf-8"
+    ) -> "DecryptionParams":
+        """
+        Reverses the process of `EncryptionParams.from_string()`.
+        Encryption algorithm parameters such as ``opslimit``, ``memlimit``,
+        and ``salt`` are automatically inferred from the file during decryption.
+
+        Tensors encrypted with `EncryptionParams.random()` or a custom binary
+        key cannot be decrypted using this method.
+        Use `DecryptionParams.from_key() instead.
+
+        Using `DecryptionParams.from_key()` is always faster than this method.
+        Use it whenever possible (when you already know the exact binary key).
+
+        Args:
+            source: Source string to use for decryption.
+            encoding: The encoding to use to convert `source` to ``bytes``
+                if provided as a ``str``. Defaults to UTF-8.
+
+        Returns:
+
+        """
+        if not source:
+            raise ValueError("Source cannot be empty")
+        if isinstance(source, str):
+            source = source.encode(encoding)
+        elif not isinstance(source, bytes):
+            raise TypeError("Invalid source type: must be str or bytes")
+        params = cls()
+        params.source = source
+        return params
+
+    @classmethod
+    @_requires_libsodium
+    def from_key(cls, key: bytes) -> "DecryptionParams":
+        if not key:
+            raise ValueError("Key cannot be empty")
+        elif len(key) != _crypt.ChunkedEncryption.KEY_BYTES:
+            raise ValueError(
+                "Invalid decryption key length,"
+                f" should be {_crypt.ChunkedEncryption.KEY_BYTES} bytes;"
+                f" got {len(key)} bytes instead."
+                " DecryptionParams.from_key() should be used with a key"
+                " read from EncryptionParams.key."
+                " To decrypt with an arbitrary string instead of a binary key,"
+                " use DecryptionParams.from_string() instead."
+            )
+        params = cls()
+        params.key = key
+        return params
+
+
+class _KeyDerivation:
+    __slots__ = ("source", "_cache")
+    source: Union[str, bytes]
+    _cache: Dict[Any, bytes]
+
+    def __init__(self, source: Union[str, bytes]):
+        self.source = source
+        self._cache = {}
+
+    def _derive_key(self, method: _crypt_info.KeyDerivationChunk) -> bytes:
+        if isinstance(method, _crypt_info.PWHashKeyDerivationChunk):
+            if method.alg != _crypt.PWHash.ALG_ARGON2ID13:
+                raise CryptographyError(
+                    f"Unsupported pwhash algorithm ({method.alg})"
+                )
+            return EncryptionParams.from_string(
+                source=self.source,
+                opslimit=method.opslimit,
+                memlimit=method.memlimit,
+                salt=method.salt,
+            ).key
+        else:
+            raise CryptographyError("Unknown key derivation method")
+
+    @staticmethod
+    def _hash_key(chunk: _crypt_info.KeyDerivationChunk) -> tuple:
+        if isinstance(chunk, _crypt_info.PWHashKeyDerivationChunk):
+            return (
+                chunk.derivation_method,
+                chunk.opslimit,
+                chunk.memlimit,
+                chunk.alg,
+                bytes(chunk.salt),
+            )
+        else:
+            raise CryptographyError("Unknown key derivation method")
+
+    def key(self, method: _crypt_info.KeyDerivationChunk) -> bytes:
+        hash_key = self._hash_key(method)
+        cached = self._cache.get(hash_key, None)
+        if cached is not None:
+            return cached
+        else:
+            key: bytes = self._derive_key(method)
+            self._cache[hash_key] = key
+            return key
+
+    def clear_cache(self) -> None:
+        self._cache.clear()
+
+
 class TensorDeserializer(
     collections.abc.Mapping, contextlib.AbstractContextManager
 ):
@@ -635,6 +1357,8 @@ class TensorDeserializer(
         verify_hash: If True, the hashes of each tensor will be verified
             against the hashes stored in the metadata. A `HashMismatchError`
             will be raised if any of the hashes do not match.
+        encryption: A `DecryptionParams` object holding a password or key
+            to use for decryption. ``None`` (the default) means no decryption.
 
     Raises:
         HashMismatchError: If ``verify_hash=True`` and a deserialized tensor
@@ -697,6 +1421,7 @@ def __init__(
         plaid_mode: bool = False,
         plaid_mode_buffers: Optional[int] = None,
         verify_hash: bool = False,
+        encryption: Optional[DecryptionParams] = None,
     ):
         # Whether to verify the hashes of the tensors when they are loaded.
         # This value is used when no verify_hash argument is passed to the
@@ -707,6 +1432,35 @@ def __init__(
         # pre-emptively and cancel it if __init__ is successful
         with self._cleanup:
             self._verify_hash = verify_hash
+            if encryption is not None and not isinstance(
+                encryption, DecryptionParams
+            ):
+                raise TypeError(
+                    "encryption parameter: expected DecryptionParams instance"
+                    f" or None, {encryption.__class__.__name__} found"
+                )
+            self._encryption = encryption
+            self._encrypted = encryption is not None
+            self._key_derivation = None
+            if self._encrypted:
+                _require_libsodium()
+                self._decryption_pool = concurrent.futures.ThreadPoolExecutor(
+                    max_workers=cpu_count,
+                    thread_name_prefix="TensorizerDecryption",
+                )
+                if self._encryption.key is None:
+                    if self._encryption.source is None:
+                        raise ValueError(
+                            "Invalid DecryptionParams,"
+                            " no key or source string provided"
+                        )
+                    else:
+                        self._key_derivation = _KeyDerivation(
+                            source=self._encryption.source
+                        )
+                        self._cleanup.callback(self._key_derivation.clear_cache)
+            else:
+                self._decryption_pool = None
 
             if isinstance(file_obj, (str, bytes, os.PathLike, int)):
                 self._file = stream_io.open_stream(file_obj, "rb")
@@ -747,12 +1501,35 @@ def __init__(
                 raise ValueError("Not a tensorizer file")
 
             # Read the file header
-            self._file_header = _FileHeader.from_io(
-                self._file,
-                accepted_versions=(
+            if self._encrypted:
+                accepted_versions = (TENSORIZER_VERSION,)
+            else:
+                accepted_versions = (
                     NON_OPAQUE_TENSORIZER_VERSION,
+                    OPAQUE_TENSORIZER_VERSION,
                     TENSORIZER_VERSION,
-                ),
+                )
+            try:
+                self._file_header = _FileHeader.from_io(
+                    self._file, accepted_versions=accepted_versions
+                )
+            except _FileHeader.InvalidVersionError as e:
+                if self._encrypted and e.version in (
+                    NON_OPAQUE_TENSORIZER_VERSION,
+                    OPAQUE_TENSORIZER_VERSION,
+                ):
+                    raise CryptographyError(
+                        "Tensor decryption was requested,"
+                        " but the file provided comes from a tensorizer version"
+                        " predating encryption, so it must not be encrypted."
+                        " Either set encryption=None on the TensorDeserializer,"
+                        " or ensure that the correct file was provided."
+                    ) from e
+                else:
+                    raise
+
+            self._has_crypt_info: bool = (
+                self._file_header.version_number >= TENSORIZER_VERSION
             )
 
             # The total size of the file.
@@ -779,8 +1556,6 @@ def __init__(
                     if filter_func(name)
                 }
 
-            self._prior_key: Optional[str] = None
-
             # We calculate the total tensor bytes here so that we can use mmap,
             # based on the total size of the tensors that we're going to read,
             # filtered by the filter_func.
@@ -960,6 +1735,8 @@ def __exit__(self, exc_type, exc_val, traceback):
 
     def close(self):
         self._cleanup.close()
+        self._encryption = None
+        self._key_derivation = None
 
     @property
     def total_bytes_read(self) -> int:
@@ -981,6 +1758,25 @@ def cache_status(self) -> Optional[str]:
         else:
             return None
 
+    def _read_single_tensor(
+        self, expected_name: str, *args, **kwargs
+    ) -> torch.Tensor:
+        tensors = tuple(self.read_tensors(*args, **kwargs, num_tensors=1))
+        num_tensors = len(tensors)
+        if num_tensors == 0:
+            raise RuntimeError("Tensor not found")
+        elif num_tensors > 1:
+            raise RuntimeError(
+                f"Found too many tensors: expected 1, found {num_tensors}"
+            )
+        *_, name, tensor = tensors[0]
+        if name != expected_name:
+            raise RuntimeError(
+                "Encountered unexpected tensor:"
+                f" expected {expected_name!r}, found {name!r}"
+            )
+        return tensor
+
     def __getitem__(self, name) -> torch.nn.Parameter:
         if name in self._cache and self._cache[name] is not None:
             return self._cache[name]
@@ -991,8 +1787,8 @@ def __getitem__(self, name) -> torch.nn.Parameter:
         # forward in a stream works well even for HTTP/HTTPS streams.
         if name in self._metadata:
             self._file.seek(self._metadata[name].offset)
-            tensor_arr = tuple(self.read_tensors(num_tensors=1))[0][3]
-            self._cache[name] = self._to_torch_parameter(tensor_arr)
+            tensor = self._read_single_tensor(name)
+            self._cache[name] = self._to_torch_parameter(tensor)
             return self._cache[name]
         else:
             raise KeyError(f"Tensor {name} not found")
@@ -1052,7 +1848,7 @@ def keys(self):
     def _verify_hashes(
         name: str,
         hashes: Iterable[TensorHash],
-        headers: bytes,
+        header_hashes: Dict[HashType, Any],
         mv: Union[memoryview, bytes],
     ) -> None:
         """
@@ -1067,7 +1863,7 @@ def _verify_hashes(
             hash_type = tensor_hash.type
             hash_body = tensor_hash.hash
             if hash_type == HashType.CRC32:
-                crc = zlib.crc32(mv, zlib.crc32(headers))
+                crc = zlib.crc32(mv, header_hashes[hash_type])
                 hash_crc = struct.unpack("<I", hash_body)[0]
                 if crc != hash_crc:
                     raise HashMismatchError(
@@ -1075,7 +1871,7 @@ def _verify_hashes(
                         f"Expected {hash_crc}, got {crc}."
                     )
             elif hash_type == HashType.SHA256:
-                sha = hashlib.sha256(headers)
+                sha = header_hashes[hash_type].copy()
                 sha.update(mv)
                 sha_digest = sha.digest()
                 if sha_digest != hash_body:
@@ -1088,6 +1884,121 @@ def _verify_hashes(
                     f"Tensor '{name}' has an invalid hash type: {hash_type}"
                 )
 
+    @staticmethod
+    def _get_encryption_method(
+        crypt_info: _crypt_info.CryptInfo,
+    ) -> _crypt_info.CryptInfoChunk:
+        encryption_method = crypt_info.find_chunks(
+            (
+                _crypt_info.XSalsa20ParallelChunk,
+                _crypt_info.XSalsa20SequentialChunk,
+            )
+        )
+        if not encryption_method:
+            raise CryptographyError("No known encryption method found in file")
+        elif len(encryption_method) > 1:
+            raise CryptographyError(
+                "Could not interpret encryption method of the file"
+            )
+        return encryption_method[0]
+
+    def _derive_encryption_key(
+        self, crypt_info: _crypt_info.CryptInfo
+    ) -> bytes:
+        if self._encryption.key is not None:
+            return self._encryption.key
+        # Requires key derivation from a source string
+        if self._key_derivation is None:
+            raise ValueError("Invalid DecryptionParams")
+        # Check for a KeyDerivationChunk
+        kd = crypt_info.find_chunks(_crypt_info.KeyDerivationChunk)
+        if not kd:
+            raise CryptographyError(
+                "Source string was provided, but the tensor was"
+                " not originally encrypted using a source string"
+                " (e.g. EncryptionParams.from_string())."
+                " DecryptionParams.from_key() must be used instead"
+            )
+        elif len(kd) > 1:
+            raise CryptographyError(
+                "Could not interpret encryption key derivation"
+                " method of the file"
+            )
+        method = typing.cast(_crypt_info.KeyDerivationChunk, kd[0])
+        return self._key_derivation.key(method)
+
+    def _get_decryption_manager(
+        self, encryption_method: _crypt_info.CryptInfoChunk, key: bytes, buffer
+    ) -> Union["_crypt.ChunkedEncryption", "_crypt.SequentialEncryption"]:
+        if isinstance(encryption_method, _crypt_info.XSalsa20ParallelChunk):
+            if encryption_method.num_macs == 1:
+                return _crypt.SequentialEncryption(
+                    key=key,
+                    buffer=buffer,
+                    nonce=encryption_method.nonce,
+                    mac=encryption_method.macs[0],
+                    intent=_crypt.SequentialEncryption.INTENT.DECRYPTION,
+                )
+            else:
+                nonces = _crypt.ChunkedEncryption.sequential_nonces(
+                    initial_nonce=encryption_method.nonce,
+                    count=encryption_method.num_macs,
+                )
+                return _crypt.ChunkedEncryption(
+                    key=key,
+                    buffer=buffer,
+                    chunk_size=encryption_method.chunk_size,
+                    nonces=nonces,
+                    macs=encryption_method.macs,
+                    executor=self._decryption_pool,
+                    intent=_crypt.ChunkedEncryption.INTENT.DECRYPTION,
+                )
+        elif isinstance(encryption_method, _crypt_info.XSalsa20SequentialChunk):
+            return _crypt.SequentialEncryption(
+                key=key,
+                buffer=buffer,
+                nonce=encryption_method.nonce,
+                mac=encryption_method.mac,
+                intent=_crypt.SequentialEncryption.INTENT.DECRYPTION,
+            )
+        else:
+            raise CryptographyError("Unknown encryption method")
+
+    def _stream_decrypt(
+        self, encryption_method: _crypt_info.CryptInfoChunk, key: bytes, buffer
+    ):
+        try:
+            with self._get_decryption_manager(
+                encryption_method, key, buffer
+            ) as crypto:
+                if isinstance(crypto, _crypt.ChunkedEncryption):
+                    fs = []
+                    for chunk in range(crypto.num_chunks):
+                        with crypto.chunk_view(chunk) as view:
+                            self._file.readinto(view)
+                        fs.append(crypto.decrypt_chunk(chunk))
+                    crypto.wait_or_raise(fs, timeout=_TIMEOUT)
+                else:
+                    self._file.readinto(buffer)
+                    crypto.decrypt()
+        except _crypt.CryptographyError as e:
+            raise CryptographyError("Tensor decryption failed") from e
+        finally:
+            del crypto
+
+    @staticmethod
+    @contextlib.contextmanager
+    def _release_on_exc(mv: memoryview):
+        try:
+            yield mv
+        except GeneratorExit:
+            del mv
+            raise
+        except BaseException:
+            mv.release()
+            del mv
+            raise
+
     def _read_numpytensors(
         self,
         filter_func: Optional[Callable[[str], Union[bool, Any]]] = None,
@@ -1132,7 +2043,9 @@ def _read_numpytensors(
             tensors_read = 0
             while num_tensors == -1 or tensors_read < num_tensors:
                 header = _TensorHeaderDeserializer.from_io(
-                    self._file, zero_hashes=True
+                    self._file,
+                    zero_hashes=True,
+                    check_crypt_info=self._has_crypt_info,
                 )
 
                 if header is None:
@@ -1163,9 +2076,30 @@ def _read_numpytensors(
 
                 self._metadata[header.name].hashes = header.hashes
 
-                # Store our raw headers with hashes zeroed out
-                # for model verification
-                self._metadata[header.name].raw_headers = header.buffer
+                header_hashes = header.compute_hashes()
+                self._metadata[header.name].header_hashes = header_hashes
+
+                is_encrypted: bool = (
+                    header.crypt_info is not None
+                    and header.crypt_info.num_chunks != 0
+                )
+                if self._encrypted and not is_encrypted:
+                    raise CryptographyError(
+                        "Tensor is not encrypted, but decryption was requested"
+                    )
+                elif is_encrypted and not self._encrypted:
+                    raise CryptographyError(
+                        "Tensor is encrypted, but decryption was not requested"
+                    )
+                elif self._encrypted or is_encrypted:
+                    assert self._encrypted and is_encrypted
+                    encryption_method = self._get_encryption_method(
+                        header.crypt_info
+                    )
+                    key = self._derive_encryption_key(header.crypt_info)
+                else:
+                    key = None
+                    encryption_method = None
 
                 # We use memoryview to avoid copying the data.
                 mv: memoryview
@@ -1175,7 +2109,6 @@ def _read_numpytensors(
                     # all the tensors. We just need to slice out the
                     # memoryview for the current tensor.
                     mv = self._buffers[header.name]
-                    self._file.readinto(mv)
                     self._allocated += header.data_length
                 elif self._plaid_mode:
                     # In plaid_mode, we don't allocate a buffer, we just
@@ -1185,7 +2118,6 @@ def _read_numpytensors(
                     # the buffer contents after we yield the tensor, which
                     # is loaded straight into the GPU memory.
                     mv = self._buffers[header.name]
-                    self._file.readinto(mv)
                 else:
                     # In lazy_load mode, we allocate a new buffer for each
                     # tensor. This is a bit slower, but it's the only way
@@ -1194,17 +2126,20 @@ def _read_numpytensors(
                     if len(buffer) != header.data_length:
                         raise RuntimeError("Header data length mismatch")
                     mv = memoryview(buffer)
+
+                if not self._encrypted or mv.nbytes == 0:
                     self._file.readinto(mv)
+                elif self._encrypted and mv.nbytes > 0:
+                    with self._release_on_exc(mv):
+                        self._stream_decrypt(encryption_method, key, mv)
 
                 if verify_hash:
-                    try:
+                    with self._release_on_exc(mv):
+                        # Releasing on an exception is necessary to prevent
+                        # a BufferError on close()
                         self._verify_hashes(
-                            header.name, header.hashes, header.buffer, mv
+                            header.name, header.hashes, header_hashes, mv
                         )
-                    except HashMismatchError:
-                        # Necessary to prevent a BufferError on close()
-                        mv.release()
-                        raise
 
                 if raw:
                     tensor = mv
@@ -1477,6 +2412,8 @@ def _optimize_plaid_mode_buffers(
                 self._buffers = unoptimized_buffers
 
     class _AtomicCountdown:
+        __slots__ = ("_count", "_condition", "_cancelled", "_initial")
+
         def __init__(self, count: int, initial: Optional[int] = None):
             if count <= 0:
                 raise ValueError("Invalid count.")
@@ -1509,10 +2446,12 @@ def trigger(self) -> None:
                 if self._count == 0:
                     self._condition.notify_all()
 
-        def reset(self) -> None:
+        def reset(self, count: Optional[int] = None) -> None:
             """Resets the internal counter."""
+            if count is not None and count <= 0:
+                raise ValueError("Invalid count.")
             with self._condition:
-                self._count = self._initial
+                self._count = self._initial if count is None else count
                 self._cancelled = False
 
         def cancel(self) -> None:
@@ -1577,17 +2516,17 @@ def _bulk_load(
         transfer_out_queue = queue.SimpleQueue()
         sentinel = object()
 
-        num_hash_tasks = 2
+        max_num_hash_tasks = 2
         tasks_per_tensor = 1
         if verify_hash:
-            tasks_per_tensor += num_hash_tasks
+            tasks_per_tensor += max_num_hash_tasks
 
         atomic_countdown: typing.Type = TensorDeserializer._AtomicCountdown
         if self._plaid_mode:
-            countdowns = [
-                atomic_countdown(tasks_per_tensor, 0)
+            countdowns = tuple(
+                atomic_countdown(tasks_per_tensor, initial=0)
                 for _ in range(self._plaid_mode_buffer_count)
-            ]
+            )
             countdown_cycle = itertools.cycle(countdowns)
         else:
             countdown_cycle = itertools.cycle((None,))
@@ -1604,7 +2543,7 @@ def cancel_thread(thread: threading.Thread):
                 ctypes.pythonapi.PyThreadState_SetAsyncExc(thread_id, exc) == 1
             )
 
-        def receive_and_check(timeout: int = 3600) -> torch.nn.Parameter:
+        def receive_and_check(timeout: int) -> torch.nn.Parameter:
             outcome = transfer_out_queue.get(timeout=timeout)
             if outcome is sentinel:
                 raise RuntimeError("Loading failed")
@@ -1622,7 +2561,7 @@ def transfer() -> None:
                 ):
                     while True:
                         next_tensor, countdown = transfer_in_queue.get(
-                            timeout=3600
+                            timeout=_TIMEOUT
                         )
                         next_tensor: torch.Tensor
                         countdown: Optional[atomic_countdown]
@@ -1631,7 +2570,7 @@ def transfer() -> None:
                         try:
                             transfer_out_queue.put(
                                 self._to_torch_parameter(next_tensor),
-                                timeout=3600,
+                                timeout=_TIMEOUT,
                             )
                         finally:
                             if countdown is not None:
@@ -1676,7 +2615,7 @@ def ready_buffers(
 
         if verify_hash:
             computation_threads = concurrent.futures.ThreadPoolExecutor(
-                max_workers=min(len(keys) * 2, os.cpu_count()),
+                max_workers=min(len(keys) * max_num_hash_tasks, cpu_count),
                 thread_name_prefix="TensorizerComputation",
             )
         else:
@@ -1691,7 +2630,7 @@ def check_hash(
             with memoryview(data).cast("B") as mv:
                 try:
                     self._verify_hashes(
-                        metadata.name, hashes, metadata.raw_headers, mv
+                        metadata.name, hashes, metadata.header_hashes, mv
                     )
                 finally:
                     if countdown is not None:
@@ -1727,18 +2666,21 @@ def read_into_buffers() -> None:
                             break
                         metadata: TensorEntry = self._metadata[name]
                         self._file.seek(metadata.offset)
-                        if countdown is not None and not countdown.wait():
-                            break
-                        for *_, tensor in self.read_tensors(
-                            num_tensors=1, verify_hash=False
+                        if countdown is not None and not countdown.wait(
+                            _TIMEOUT
                         ):
-                            pass
+                            break
+                        tensor = self._read_single_tensor(
+                            name, verify_hash=False
+                        )
                         if stop:
                             break
-                        tensor: torch.Tensor
+                        hashing_required = computation_threads is not None
                         if countdown is not None:
-                            countdown.reset()
-                        if computation_threads is not None:
+                            countdown.reset(
+                                1 + len(metadata.hashes) * hashing_required
+                            )
+                        if hashing_required:
                             check_hashes(name, tensor, countdown)
                         transfer_in_queue.put_nowait((tensor, countdown))
             except (Cancelled, Exception) as e:
@@ -1759,22 +2701,22 @@ def read_into_buffers() -> None:
 
             try:
                 for key in keys[:-1]:
-                    self._cache[key] = receive_and_check()
+                    self._cache[key] = receive_and_check(_TIMEOUT)
                     yield self._cache[key]
                 # Stop before yielding the final tensor
                 # to catch up on hash verification
-                read_thread.join(timeout=3600)
+                read_thread.join(timeout=_TIMEOUT)
                 if computation_threads is not None:
                     # At this point, all checks have been added to `checks`
                     computation_threads.shutdown(wait=True)
                     # At this point, all checks have finished
                     for check in checks:
                         # This will raise if any of the checks failed
-                        check.result(timeout=3600)
+                        check.result(timeout=_TIMEOUT)
                     checks.clear()
-                self._cache[keys[-1]] = receive_and_check()
+                self._cache[keys[-1]] = receive_and_check(_TIMEOUT)
                 transfer_in_queue.put_nowait((sentinel, None))
-                transfer_thread.join(timeout=3600)
+                transfer_thread.join(timeout=_TIMEOUT)
                 yield self._cache[keys[-1]]
             except Exception:
                 stop = True
@@ -1787,13 +2729,13 @@ def read_into_buffers() -> None:
                     transfer_thread.join(timeout=4)
                     if transfer_thread.is_alive():
                         cancel_thread(transfer_thread)
-                        transfer_thread.join(timeout=3600)
+                        transfer_thread.join(timeout=_TIMEOUT)
                 if read_thread.is_alive():
                     # A graceful exit is again preferred, but not necessary
                     read_thread.join(timeout=2)
                     if read_thread.is_alive():
                         cancel_thread(read_thread)
-                        read_thread.join(timeout=3600)
+                        read_thread.join(timeout=_TIMEOUT)
                 for check in checks:
                     check.cancel()
                 raise
@@ -1931,7 +2873,7 @@ def verify_module(
                     self._verify_hashes(
                         name,
                         entry.hashes,
-                        entry.raw_headers,
+                        entry.header_hashes,
                         mv,
                     )
                 results.append((name, True))
@@ -1990,7 +2932,10 @@ class TensorSerializer:
     Args:
         file_obj: A file-like object or path to a file to write to. The path
             can be a S3 URI.
-        compress_tensors: If True, compress the tensors using lz4. This
+        encryption: An `EncryptionParams` object holding a password or key
+            to use for encryption. If None, no encryption will be used.
+        compress_tensors: Not implemented. Specifying this option does nothing.
+            Previously, if True, compress the tensors using lz4. This
             exists as an internal curiosity as it doesn't seem to make
             much of a difference in practice.
 
@@ -2034,6 +2979,8 @@ def __init__(
             int,
         ],
         compress_tensors: bool = False,
+        *,
+        encryption: Optional[EncryptionParams] = None,
     ) -> None:
         if isinstance(file_obj, (str, bytes, os.PathLike, int)):
             self._file = stream_io.open_stream(file_obj, "wb+")
@@ -2041,6 +2988,24 @@ def __init__(
             self._mode_check(file_obj)
             self._file = file_obj
 
+        if encryption is not None and not isinstance(
+            encryption, EncryptionParams
+        ):
+            raise TypeError(
+                "encryption parameter: expected EncryptionParams instance"
+                f" or None, {encryption.__class__.__name__} found"
+            )
+        self._encryption = encryption
+        self._encrypted = encryption is not None
+        self._used_nonces: Optional[Set[bytes]]
+        if self._encrypted:
+            _require_libsodium()
+            self._crypt_chunk_size = 2 << 20
+            self._used_nonces = set()
+        else:
+            self._crypt_chunk_size = None
+            self._used_nonces = None
+
         # Get information about the file object's capabilities
         _fd_getter = getattr(self._file, "fileno", None)
         self._fd = _fd_getter() if callable(_fd_getter) else None
@@ -2070,14 +3035,16 @@ def __init__(
         self.total_compressed_tensor_bytes = 0
         self.compress_tensors = compress_tensors
 
+        self._pools = []
         # This thread pool handles CPU-bound tasks like hashing.
         # Hashing from the Python standard library can benefit from
         # multithreading in spite of the GIL because CPython's hash function
         # implementations release the GIL during longer hash computations.
         self._computation_pool = concurrent.futures.ThreadPoolExecutor(
-            max_workers=os.cpu_count(),
+            max_workers=cpu_count,
             thread_name_prefix="TensorizerComputation",
         )
+        self._pools.append(self._computation_pool)
 
         # There is no use spawning many writer threads when they share a lock.
         max_concurrent_writers = 4 if concurrent_writes_possible else 1
@@ -2088,6 +3055,7 @@ def __init__(
             max_workers=max_concurrent_writers,
             thread_name_prefix="TensorizerWriter",
         )
+        self._pools.append(self._writer_pool)
 
         self._tensor_count_update_lock = threading.Lock()
 
@@ -2102,6 +3070,22 @@ def __init__(
             max_workers=max_concurrent_writers,
             thread_name_prefix="TensorizerHeaderWriter",
         )
+        self._pools.append(self._header_writer_pool)
+
+        if self._encrypted:
+            self._encryption_pool = concurrent.futures.ThreadPoolExecutor(
+                max_workers=max_concurrent_writers,
+                thread_name_prefix="TensorizerEncryption",
+            )
+            self._pools.append(self._encryption_pool)
+
+            self._decryption_pool = concurrent.futures.ThreadPoolExecutor(
+                max_workers=max_concurrent_writers,
+                thread_name_prefix="TensorizerDecryption",
+            )
+            self._pools.append(self._decryption_pool)
+        else:
+            self._encryption_pool = self._decryption_pool = None
 
         # Implementation detail for CPython: ThreadPoolExecutor objects
         # use an instance of queue.SimpleQueue as a FIFO work queue,
@@ -2115,6 +3099,11 @@ def __init__(
 
         # Tracks work submitted to all pools to wait for pending work to finish.
         self._jobs: List[concurrent.futures.Future] = []
+        # Tracks work submitted to the decryption pool to prevent conflicting,
+        # overlapping in-place operations on tensors using shared storage.
+        self._decryption_jobs: typing.MutableMapping[
+            int, concurrent.futures.Future
+        ] = (weakref.WeakValueDictionary() if self._encrypted else {})
 
         if self.compress_tensors:
             import lz4.frame
@@ -2127,15 +3116,23 @@ def __init__(
         self._file.write(TENSORIZER_MAGIC)
 
         # Write file header metadata
+        if not self._encrypted:
+            # Can't tell if OPAQUE_TENSORIZER_VERSION is needed
+            # until a tensor is written later with an opaque dtype,
+            # so assume it is non-opaque until then.
+            version_number = NON_OPAQUE_TENSORIZER_VERSION
+        else:
+            # File encryption requires a newer tensorizer version
+            version_number = TENSORIZER_VERSION
         self._file_header_loc = self._file.tell()
         self._file_header = _FileHeader(
-            version_number=NON_OPAQUE_TENSORIZER_VERSION,
+            version_number=version_number,
             tensor_size=0,
             tensor_count=0,
         )
         self._file.write(self._file_header.to_bytes())
 
-        # Reserve 256kb for metadata.
+        # Reserve 256 KiB for metadata.
         metadata_size = 256 * 1024
         self._file.write(struct.pack("<Q", metadata_size))
         self._metadata_loc = self._file.tell()
@@ -2181,7 +3178,9 @@ def _sync_prologue_state(self):
         self._file.seek(curr)
         self._flush()
 
-    def _pwrite(self, data, offset: int, verify: bool = True) -> int:
+    def _pwrite(
+        self, data, offset: int, verify: Union[bool, int] = True
+    ) -> int:
         """
         Thread-safe file write that leaves the file offset unchanged.
 
@@ -2201,15 +3200,21 @@ def _pwrite(self, data, offset: int, verify: bool = True) -> int:
         # based on the capabilities of the platform and the file object used
         raise RuntimeError("pwrite was called before being initialized")
 
-    def _pwrite_syscall(self, data, offset: int, verify: bool = True) -> int:
+    def _pwrite_syscall(
+        self, data, offset: int, verify: Union[bool, int] = True
+    ) -> int:
         # This implementation of pwrite uses a Unix syscall, and is safe to
         # run even between normal file writes.
         bytes_written = os.pwrite(self._fd, data, offset)
-        if verify:
-            self._verify_bytes_written(bytes_written, data)
+        if isinstance(verify, int):
+            self._verify_bytes_written(bytes_written, verify)
+        elif verify:
+            self._verify_bytes_written(bytes_written, self._buffer_size(data))
         return bytes_written
 
-    def _pwrite_fallback(self, data, offset: int, verify: bool = True) -> int:
+    def _pwrite_fallback(
+        self, data, offset: int, verify: Union[bool, int] = True
+    ) -> int:
         # This implementation of pwrite uses a lock shared with all writers
         # for the entire file object. It is not safe to run this
         # concurrently with any other code that could modify the file offset
@@ -2220,16 +3225,19 @@ def _pwrite_fallback(self, data, offset: int, verify: bool = True) -> int:
                 self._file.seek(offset)
             bytes_written = self._file.write(data)
             self._file.seek(old_pos)
-        if verify:
-            self._verify_bytes_written(bytes_written, data)
+        if isinstance(verify, int):
+            self._verify_bytes_written(bytes_written, verify)
+        elif verify:
+            self._verify_bytes_written(bytes_written, self._buffer_size(data))
         return bytes_written
 
     @staticmethod
-    def _verify_bytes_written(bytes_written: int, data_written):
+    def _buffer_size(buffer: Union[memoryview, Any]) -> int:
         # For typed buffers (e.g. arrays) the len() isn't the number of bytes
-        expected_bytes_written = getattr(
-            data_written, "nbytes", len(data_written)
-        )
+        return getattr(buffer, "nbytes", len(buffer))
+
+    @staticmethod
+    def _verify_bytes_written(bytes_written: int, expected_bytes_written: int):
         if bytes_written != expected_bytes_written:
             raise OSError(
                 f"pwrite failed to write correctly: {bytes_written} bytes were"
@@ -2259,17 +3267,16 @@ def __del__(self):
             self._file.close()
 
     def _shutdown_thread_pools(self):
-        for pool in "_computation_pool", "_writer_pool", "_header_writer_pool":
-            thread_pool = getattr(self, pool, None)
-            if thread_pool is not None:
-                for j in self._jobs:
-                    j.cancel()
-                thread_pool.shutdown(wait=False)
+        for j in getattr(self, "_jobs", ()):
+            j.cancel()
+        for thread_pool in getattr(self, "_pools", ()):
+            thread_pool.shutdown(wait=False)
 
     def _synchronize_pools(self):
         for j in self._jobs:
-            j.result(timeout=3600)
+            j.result(timeout=_TIMEOUT)
         self._jobs.clear()
+        self._decryption_jobs.clear()
 
     def close(self) -> None:
         """
@@ -2289,6 +3296,28 @@ def close(self) -> None:
         #     logger.info(f"Comp'd bytes: {self.total_compressed_tensor_bytes}")
         #     logger.info(f"Ratio: {compression_ratio:.2f}")
 
+    def _new_nonces(self, count: int) -> Tuple[bytes, ...]:
+        if count < 0:
+            raise ValueError("Invalid nonce count")
+        elif count == 0:
+            return ()
+        elif self._used_nonces is None:
+            raise RuntimeError(
+                "Tried to create cryptographic nonces while"
+                " encryption is disabled"
+            )
+        nonces = tuple(
+            _crypt.ChunkedEncryption.sequential_nonces(
+                initial_nonce=_crypt.ChunkedEncryption.random_nonce(),
+                count=count,
+            )
+        )
+
+        if self._used_nonces.intersection(nonces):
+            raise RuntimeError("Illegal nonce reuse")
+        self._used_nonces.update(nonces)
+        return nonces
+
     def write_tensor(
         self,
         idx,
@@ -2342,6 +3371,7 @@ def _write_tensor(
         *,
         _synchronize: bool = True,
         _start_pos: Optional[int] = None,
+        _temporary_buffer: bool = False,
     ) -> int:
         """
         Underlying implementation for `write_tensor()`,
@@ -2362,26 +3392,82 @@ def _write_tensor(
                 writes starting at the current file offset.
         """
         if isinstance(tensor, torch.Tensor):
-            numpy_tensor = _NumpyTensor.from_tensor(tensor)
+            if not tensor.is_contiguous():
+                _temporary_buffer = True
+            numpy_tensor = _NumpyTensor.from_tensor(tensor.contiguous())
         else:
-            numpy_tensor = _NumpyTensor.from_array(tensor)
+            if (
+                isinstance(tensor, numpy.ndarray)
+                and not tensor.flags.c_contiguous
+                and hasattr(numpy, "ascontiguousarray")
+            ):
+                numpy_tensor = _NumpyTensor.from_array(
+                    numpy.ascontiguousarray(tensor)
+                )
+                _temporary_buffer = True
+            else:
+                numpy_tensor = _NumpyTensor.from_array(tensor)
 
         dtype_name = numpy_tensor.numpy_dtype
         if numpy_tensor.is_opaque:
             # The datatype name needs to contain both the numpy dtype that the
             # data is serialized as and the original torch dtype.
             dtype_name += OPAQUE_DTYPE_SEP + numpy_tensor.torch_dtype
-            self._file_header.version_number = TENSORIZER_VERSION
+            self._file_header.version_number = max(
+                OPAQUE_TENSORIZER_VERSION,
+                self._file_header.version_number,
+            )
 
-        tensor = numpy_tensor.data
-        tensor_memory = numpy_tensor.data.data
-        tensor_size = tensor.nbytes
+        tensor: numpy.ndarray = numpy_tensor.data
+        tensor_memory: memoryview = numpy_tensor.data.data
+        tensor_size: int = tensor.nbytes
+        if tensor_memory.nbytes != tensor_size:
+            raise ValueError(
+                f"Cannot serialize tensor {name!r}:"
+                f" buffer size of underlying memory ({tensor_memory.nbytes})"
+                f" doesn't match reported size ({tensor_size})"
+            )
         name_bytes = name.encode("utf-8")
         dtype_bytes = dtype_name.encode("utf-8")
         if len(dtype_bytes) >= 256:
             raise ValueError("dtype name length should be less than 256")
         shape = tensor.shape
         header_pos = self._file.tell() if _start_pos is None else _start_pos
+
+        if self._encrypted:
+            chunks = _Chunked(
+                total_size=tensor_memory.nbytes,
+                chunk_size=self._crypt_chunk_size,
+            )
+            nonces = self._new_nonces(chunks.count)
+            encryptor = _crypt.ChunkedEncryption(
+                key=self._encryption.key,
+                buffer=tensor_memory,
+                chunk_size=self._crypt_chunk_size,
+                nonces=nonces,
+                executor=self._computation_pool,
+            )
+
+            key_derivation_chunk = self._encryption._crypt_info_chunk()
+            encryption_algorithm_chunk = _crypt_info.XSalsa20ParallelChunk(
+                chunk_size=self._crypt_chunk_size,
+                nonce=nonces[0],
+                macs=encryptor.macs,
+            )
+            if key_derivation_chunk is not None:
+                chunks = (key_derivation_chunk, encryption_algorithm_chunk)
+            else:
+                chunks = (encryption_algorithm_chunk,)
+            crypt_info = _crypt_info.CryptInfo(chunks)
+        else:
+            encryptor = None
+            if self._file_header.version_number == TENSORIZER_VERSION:
+                crypt_info = _crypt_info.CryptInfo()
+            else:
+                crypt_info = None
+
+        include_crc32: bool = not self._encrypted
+
         header = _TensorHeaderSerializer(
             idx,
             tensor_type,
@@ -2390,6 +3476,9 @@ def _write_tensor(
             shape,
             tensor_size,
             header_pos,
+            include_crc32=include_crc32,
+            include_sha256=True,
+            crypt_info=crypt_info,
         )
 
         tensor_pos = header_pos + header.data_offset
@@ -2401,12 +3490,13 @@ def _write_tensor(
             raise RuntimeError("Metadata overflow")
 
         metadata_pos = self._metadata_cur
-        self._metadata_cur += len(metadata)
+        metadata_len = len(metadata)
+        self._metadata_cur += metadata_len
 
         # This task is I/O-bound and has no prerequisites,
         # so it goes into the regular writer pool.
         def write_metadata():
-            self._pwrite(metadata, metadata_pos)
+            self._pwrite(metadata, metadata_pos, verify=metadata_len)
 
         self._jobs.append(self._writer_pool.submit(write_metadata))
 
@@ -2414,43 +3504,143 @@ def write_metadata():
 
         # These two tasks are CPU-bound and don't block the GIL,
         # so they go into the computation thread pool.
-        def compute_crc32():
-            crc32 = zlib.crc32(header.buffer)
+        def compute_crc32(prerequisite: Optional[concurrent.futures.Future]):
+            if prerequisite is not None:
+                prerequisite.result(_TIMEOUT)
+            crc32 = header.compute_crc32()
             return zlib.crc32(tensor_memory, crc32)
 
-        def compute_sha256():
-            sha256 = hashlib.sha256(header.buffer)
+        def compute_sha256(prerequisite: Optional[concurrent.futures.Future]):
+            if prerequisite is not None:
+                prerequisite.result(_TIMEOUT)
+            sha256 = header.compute_sha256()
             sha256.update(tensor_memory)
             return sha256.digest()
 
         # This task is I/O-bound and dependent on the previous two tasks,
         # so it goes into the header writer pool.
         def commit_header(
-            crc32_future: concurrent.futures.Future,
-            sha256_future: concurrent.futures.Future,
+            crc32_future: Optional[concurrent.futures.Future],
+            sha256_future: Optional[concurrent.futures.Future],
+            encrypt_future: Optional[concurrent.futures.Future],
         ):
-            crc32 = crc32_future.result(3600)
-            sha256 = sha256_future.result(3600)
-            header.add_crc32(crc32)
-            header.add_sha256(sha256)
-            self._pwrite(header.buffer, header_pos)
-
-        crc32_task = self._computation_pool.submit(compute_crc32)
-        sha256_task = self._computation_pool.submit(compute_sha256)
-        commit_header_task = self._header_writer_pool.submit(
-            commit_header, crc32_task, sha256_task
+            crc32 = sha256 = None
+            if crc32_future is not None:
+                crc32 = crc32_future.result(_TIMEOUT)
+            if sha256_future is not None:
+                sha256 = sha256_future.result(_TIMEOUT)
+            if encrypt_future is not None:
+                encrypt_future.result(_TIMEOUT)
+            # These must be written only after all other futures complete
+            # to prevent a race condition from other threads hashing
+            # a partially-filled-in hash section
+            if crc32_future is not None:
+                header.add_crc32(crc32)
+            if sha256_future is not None:
+                header.add_sha256(sha256)
+            if encrypt_future is not None:
+                header.update_crypt_info()
+            self._pwrite(header.buffer, header_pos, verify=header.data_offset)
+
+        hash_tasks = []
+        if self._encrypted and not _temporary_buffer:
+            # If multiple tensors share memory, and were encrypted in-place,
+            # then this must not start hashing until any previous decryption
+            # tasks have restored this memory to its original state
+            mem_pointer = tensor.__array_interface__["data"][0]
+            pending_decryption = self._decryption_jobs.get(mem_pointer, None)
+        else:
+            mem_pointer = None
+            pending_decryption = None
+        if include_crc32:
+            crc32_task = self._computation_pool.submit(
+                compute_crc32, pending_decryption
+            )
+            hash_tasks.append(crc32_task)
+        else:
+            crc32_task = None
+        sha256_task = self._computation_pool.submit(
+            compute_sha256, pending_decryption
         )
-        self._jobs.extend((crc32_task, sha256_task, commit_header_task))
+        hash_tasks.append(sha256_task)
+        self._jobs.extend(hash_tasks)
+
+        def encrypt(prerequisites: Iterable[concurrent.futures.Future]):
+            fs = concurrent.futures.wait(prerequisites, timeout=_TIMEOUT)
+            for f in fs.done:
+                # Raise exceptions
+                f.result()
+            for f in fs.not_done:
+                # Raise timeouts
+                f.result(0)
+            try:
+                encryptor.encrypt_all(
+                    wait=True,
+                    timeout=_TIMEOUT,
+                )
+            except _crypt.CryptographyError as e:
+                raise CryptographyError("Tensor encryption failed") from e
 
-        # This task is I/O-bound and has no prerequisites,
-        # so it goes into the regular writer pool.
-        def write_tensor_data():
-            bytes_written = self._pwrite(tensor_memory, tensor_pos)
+        # This task is I/O-bound, so it goes into the regular writer pool.
+        def write_tensor_data(
+            prerequisite: Optional[concurrent.futures.Future], size: int
+        ):
+            if prerequisite is not None:
+                prerequisite.result(_TIMEOUT)
+            bytes_written = self._pwrite(tensor_memory, tensor_pos, verify=size)
             with self._tensor_count_update_lock:
                 self._file_header.tensor_count += 1
                 self._file_header.tensor_size += bytes_written
 
-        self._jobs.append(self._writer_pool.submit(write_tensor_data))
+        def decrypt(prerequisite: concurrent.futures.Future):
+            try:
+                prerequisite.result(_TIMEOUT)
+            finally:
+                # Try to decrypt again even if writing to disk failed
+                # to avoid exiting with the tensor memory in a modified state
+                fs = encryptor.decrypt_all(wait=False)
+                try:
+                    _crypt.ChunkedEncryption.wait_or_raise(
+                        fs,
+                        timeout=_TIMEOUT,
+                        return_when=concurrent.futures.ALL_COMPLETED,
+                    )
+                except _crypt.CryptographyError as e:
+                    try:
+                        original_exc = prerequisite.exception(timeout=0)
+                    except (
+                        concurrent.futures.TimeoutError,
+                        concurrent.futures.CancelledError,
+                    ):
+                        original_exc = None
+                    raise CryptographyError(
+                        "Restoring encrypted tensor data in memory failed"
+                    ) from (original_exc if original_exc is not None else e)
+
+        # Encrypt the tensor memory in-place before writing
+        if self._encrypted:
+            encrypt_task = self._encryption_pool.submit(encrypt, hash_tasks)
+            self._jobs.append(encrypt_task)
+        else:
+            encrypt_task = None
+
+        commit_header_task = self._header_writer_pool.submit(
+            commit_header, crc32_task, sha256_task, encrypt_task
+        )
+        self._jobs.append(commit_header_task)
+
+        # Write the potentially-encrypted tensor memory to the file
+        write_task = self._writer_pool.submit(
+            write_tensor_data, encrypt_task, tensor_size
+        )
+        self._jobs.append(write_task)
+        # Decrypt the memory after writing is finished, if it was encrypted
+        if self._encrypted and not _temporary_buffer:
+            decrypt_task = self._decryption_pool.submit(decrypt, write_task)
+            self._jobs.append(decrypt_task)
+            assert mem_pointer is not None
+            self._decryption_jobs[mem_pointer] = decrypt_task
+
         tensor_endpos = tensor_pos + tensor_size
 
         # Update our prologue.
@@ -2516,7 +3706,7 @@ def _transfer():
                 for t in tensors:
                     if transfer_finished:
                         break
-                    transferred.put(t.cpu().detach(), timeout=3600)
+                    transferred.put(t.cpu().detach(), timeout=_TIMEOUT)
                 else:
                     # Sentinel
                     transferred.put(None)
@@ -2539,7 +3729,7 @@ def _interrupt_transfer():
                     pass
 
         return (
-            iter(lambda: transferred.get(timeout=3600), None),
+            iter(lambda: transferred.get(timeout=_TIMEOUT), None),
             _interrupt_transfer,
         )
 
@@ -2557,7 +3747,9 @@ def _bulk_write(self, tensors: Iterable[_WriteSpec]):
         fallocate = getattr(os, "posix_fallocate", None)
         if fallocate and self._fd:
             size = sum(len(t.name) for t in tensors)
-            size += sum(t.tensor.element_size() * t.tensor.nelement() for t in tensors)
+            size += sum(
+                t.tensor.element_size() * t.tensor.nelement() for t in tensors
+            )
             # Rough underestimate of header size
             header_min_size = 24
             size += header_min_size * len(tensors)
@@ -2578,22 +3770,53 @@ def _bulk_write(self, tensors: Iterable[_WriteSpec]):
             transferred = interrupt_transfer = None
         del cuda_tensors
 
+        if self._encrypted:
+            shared = []
+            seen_addresses = set()
+            for t in reversed(tensors):
+                if t.tensor.device.type == "cuda":
+                    shared.append(False)
+                else:
+                    address = t.tensor.data_ptr()
+                    shared.append(address in seen_addresses)
+                    seen_addresses.add(address)
+            del seen_addresses
+        else:
+            shared = [False] * len(tensors)
+
         try:
             while tensors:
                 idx, name, tensor_type, tensor, callback = tensors.popleft()
+                is_shared = shared.pop()
+                self._idx = idx
                 if tensor.device.type == "cuda":
                     tensor = next(transferred)
+                    temp_tensor = True
+                elif is_shared and self._encrypted:
+                    # Un-shares tensor memory in preparation for in-place
+                    # operations on the buffer that would otherwise conflict
+                    # with one another. Full support for shared-memory tensors
+                    # (e.g. if they were only written once) could make
+                    # this unnecessary, once implemented.
+                    # Another option would be to reuse the same encrypted
+                    # weights and decrypt them at the end. This would require
+                    # confirming that the tensor data regions are actually
+                    # identical, and don't just overlap.
+                    tensor = tensor.clone().detach()
+                    temp_tensor = True
+                else:
+                    temp_tensor = False
                 next_pos = self._write_tensor(
-                    self._idx,
+                    idx,
                     name,
                     tensor_type,
                     tensor,
                     _synchronize=False,
                     _start_pos=next_pos,
+                    _temporary_buffer=temp_tensor,
                 )
                 if callback is not None:
                     callback()
-                self._idx = idx
         except Exception:
             if interrupt_transfer is not None:
                 interrupt_transfer()
diff --git a/tensorizer/stream_io.py b/tensorizer/stream_io.py
index d47873ee..dbac71a8 100644
--- a/tensorizer/stream_io.py
+++ b/tensorizer/stream_io.py
@@ -186,7 +186,7 @@ def __hash__(self):
         return hash(self._curl_flags)
 
 
-class CURLStreamFile:
+class CURLStreamFile(io.RawIOBase):
     """
     CURLStreamFile implements a file-like object around an HTTP download, the
     intention being to not buffer more than we have to. It is intended for
@@ -307,7 +307,7 @@ def __init__(
 
         self._curr = 0 if begin is None else begin
         self._end = end
-        self.closed = False
+        self._closed = False
 
     def _init_vars(self):
         self.popen_latencies: List[float] = getattr(self, "popen_latencies", [])
@@ -444,7 +444,7 @@ def _read_until(
                     ret_buff = ba
             self.bytes_read += ret_buff_sz
             if ret_buff_sz != rq_sz:
-                self.closed = True
+                self._closed = True
                 self._curl.terminate()
                 raise IOError(f"Requested {rq_sz} != {ret_buff_sz}")
             self._curr += ret_buff_sz
@@ -464,23 +464,21 @@ def readinto(self, ba: bytearray) -> int:
         return self._read_until(goal_position, ba)
 
     def read(self, size=None) -> bytes:
-        if self.closed:
+        if self._closed:
             raise IOError("CURLStreamFile closed.")
         if size is None:
             return self._curl.stdout.read()
         goal_position = self._curr + size
         return self._read_until(goal_position)
 
-    @staticmethod
-    def writable() -> bool:
+    def writable(self) -> bool:
         return False
 
-    @staticmethod
-    def fileno() -> int:
+    def fileno(self) -> int:
         return -1
 
     def close(self):
-        self.closed = True
+        self._closed = True
         if self._curl is not None:
             if self._curl.poll() is None:
                 self._curl.stdout.close()
@@ -492,7 +490,11 @@ def close(self):
                 self._curl.stdout.close()
             self._curl = None
 
-    def readline(self):
+    @property
+    def closed(self):
+        return self._closed
+
+    def readline(self, size=-1) -> bytes:
         raise NotImplementedError("Unimplemented")
 
     """
diff --git a/tensorizer/utils.py b/tensorizer/utils.py
index a63e684d..ee5ae5e5 100644
--- a/tensorizer/utils.py
+++ b/tensorizer/utils.py
@@ -22,10 +22,13 @@
 try:
     import pynvml
 
-    try:
-        pynvml.nvmlInit()
-    except pynvml.nvml.NVMLError_LibraryNotFound:
+    if not hasattr(pynvml, "nvml"):
         pynvml = None
+    else:
+        try:
+            pynvml.nvmlInit()
+        except pynvml.nvml.NVMLError_LibraryNotFound:
+            pynvml = None
 except ImportError:
     pynvml = None
 


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 59efb661..de9616ed 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -22,5 +22,8 @@ jobs:
       - name: Install Redis
         run: sudo apt-get install -y redis-server
 
+      - name: Install libsodium
+        run: sudo apt-get install -y libsodium23
+
       - name: Run tests
         run: python -m unittest discover tests/ --verbose
diff --git a/tests/__init__.py b/tests/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/test_serialization.py b/tests/test_serialization.py
index 6c9e9da1..88238214 100644
--- a/tests/test_serialization.py
+++ b/tests/test_serialization.py
@@ -11,7 +11,7 @@
 import tempfile
 import time
 import unittest
-from typing import Mapping, NamedTuple, Tuple
+from typing import Mapping, NamedTuple, Optional
 from unittest.mock import patch
 
 import torch
@@ -23,14 +23,21 @@
 from transformers import AutoConfig, AutoModelForCausalLM, AutoTokenizer
 
 from tensorizer import (
+    DecryptionParams,
+    EncryptionParams,
     TensorDeserializer,
     TensorSerializer,
     serialization,
     stream_io,
     utils,
 )
+from tensorizer._crypt import available as encryption_available
 from tensorizer.serialization import TensorHash, TensorType
-from test_stream_io import start_redis, teardown_redis
+
+try:
+    from test_stream_io import start_redis, teardown_redis
+except ImportError:
+    from .test_stream_io import start_redis, teardown_redis
 
 model_name = "EleutherAI/gpt-neo-125M"
 num_hellos = 400
@@ -45,17 +52,23 @@ class SerializeMethod(enum.Enum):
     StateDict = 2
 
 
+class SerializationResult(NamedTuple):
+    filename: str
+    orig_sd: dict
+
+
 def serialize_model(
     model_name: str,
     device: str,
     method: SerializeMethod = SerializeMethod.Module,
-) -> Tuple[str, dict]:
+    encryption: Optional[EncryptionParams] = None,
+) -> SerializationResult:
     model = AutoModelForCausalLM.from_pretrained(model_name).to(device)
     sd = model.state_dict()
     out_file = tempfile.NamedTemporaryFile("wb+", delete=False)
     try:
         start_time = time.monotonic()
-        serializer = TensorSerializer(out_file)
+        serializer = TensorSerializer(out_file, encryption=encryption)
         if method is SerializeMethod.Module:
             serializer.write_module(model)
         elif method is SerializeMethod.StateDict:
@@ -68,7 +81,17 @@ def serialize_model(
     except Exception:
         os.unlink(out_file.name)
         raise
-    return out_file.name, sd
+    return SerializationResult(out_file.name, sd)
+
+
+@contextlib.contextmanager
+@functools.wraps(serialize_model)
+def serialize_model_temp(*args, **kwargs):
+    filename = serialize_model(*args, **kwargs).filename
+    try:
+        yield filename
+    finally:
+        os.unlink(filename)
 
 
 # Reducing a tensor to a hash makes it faster to compare against the reference
@@ -302,13 +325,212 @@ def test_persistent_buffers(self):
                     os.unlink(tensorized_file.name)
 
 
+@unittest.skipUnless(
+    encryption_available,
+    reason="libsodium must be installed to test encryption",
+)
+class TestEncryption(unittest.TestCase):
+    @staticmethod
+    def _serialize(enc: Optional[EncryptionParams], device=default_device):
+        return serialize_model_temp(
+            model_name,
+            device,
+            method=SerializeMethod.Module,
+            encryption=enc,
+        )
+
+    @staticmethod
+    def _test_first_key_negative(obj):
+        k = next(iter(obj.keys()))
+        if obj[k] is not None:
+            raise RuntimeError()
+
+    def test_encryption(self):
+        fixed_salt = bytes(16)
+        low_cpu = EncryptionParams.OpsLimit.MIN
+        low_mem = EncryptionParams.MemLimit.MIN
+        encryption = EncryptionParams.from_string(
+            source="test",
+            opslimit=low_cpu,
+            memlimit=low_mem,
+            salt=fixed_salt,
+        )
+        decryption = DecryptionParams.from_string(source="test")
+        incorrect_decryption = DecryptionParams.from_string(source="tset")
+        device = default_device
+
+        with self._serialize(encryption, device) as encrypted_model:
+            if device == "cuda":
+                modes = (
+                    (False, False),
+                    (False, True),
+                    (True, True),
+                )
+            else:
+                modes = (
+                    (False, False),
+                    (True, False),
+                )
+            for lazy_load, plaid_mode in modes:
+                # Ensure that it works when given a key
+                with self.subTest(
+                    msg="Deserializing with a correct key",
+                    device=device,
+                    lazy_load=lazy_load,
+                    plaid_mode=plaid_mode,
+                ), open(encrypted_model, "rb") as in_file, TensorDeserializer(
+                    in_file,
+                    device=device,
+                    lazy_load=lazy_load,
+                    plaid_mode=plaid_mode,
+                    verify_hash=True,
+                    encryption=decryption,
+                ) as deserialized:
+                    check_deserialized(
+                        self,
+                        deserialized,
+                        model_name,
+                    )
+                    del deserialized
+                gc.collect()
+
+            # Ensure that it fails to load when not given a key
+            with self.subTest(
+                msg="Deserializing with a missing key"
+            ), self.assertRaises(serialization.CryptographyError), open(
+                encrypted_model, "rb"
+            ) as in_file, TensorDeserializer(
+                in_file,
+                device=device,
+                lazy_load=True,
+                encryption=None,
+            ) as deserialized:
+                self._test_first_key_negative(deserialized)
+                del deserialized
+            gc.collect()
+
+            # Ensure that it fails to load when given the wrong key
+            with self.subTest(
+                msg="Deserializing with an incorrect key"
+            ), self.assertRaises(serialization.CryptographyError), open(
+                encrypted_model, "rb"
+            ) as in_file, TensorDeserializer(
+                in_file,
+                device=device,
+                lazy_load=True,
+                encryption=incorrect_decryption,
+            ) as deserialized:
+                self._test_first_key_negative(deserialized)
+                del deserialized
+            gc.collect()
+
+        with self._serialize(None, device) as unencrypted_model:
+            # Ensure that it fails to load an unencrypted model
+            # when expecting encryption
+            with self.subTest(
+                msg="Deserializing an unencrypted model with a key"
+            ), self.assertRaises(serialization.CryptographyError), open(
+                unencrypted_model, "rb"
+            ) as in_file, TensorDeserializer(
+                in_file,
+                device=device,
+                lazy_load=True,
+                encryption=decryption,
+            ) as deserialized:
+                self._test_first_key_negative(deserialized)
+                del deserialized
+            gc.collect()
+
+    def test_from_string(self):
+        fixed_salt = bytes(16)
+        encryption = EncryptionParams.from_string(
+            source="test", salt=fixed_salt
+        )
+        decryption = DecryptionParams.from_string(source="test")
+        incorrect_decryption = DecryptionParams.from_string(source="tset")
+        self._test_encryption_params(
+            encryption, decryption, incorrect_decryption
+        )
+
+    def test_random_encryption_params(self):
+        encryption = EncryptionParams.random()
+        decryption = DecryptionParams.from_key(encryption.key)
+        incorrect_decryption = DecryptionParams.from_key(
+            bytes(len(encryption.key))
+        )
+        self._test_encryption_params(
+            encryption, decryption, incorrect_decryption
+        )
+
+    def _test_encryption_params(
+        self,
+        encryption: EncryptionParams,
+        decryption: DecryptionParams,
+        incorrect_decryption: DecryptionParams,
+    ):
+        device = default_device
+        plaid_mode = device != "cpu"
+
+        with self._serialize(encryption, device) as encrypted_model:
+            # Ensure that it works when given a key
+            with self.subTest(
+                msg="Deserializing with a correct key",
+                device=device,
+                lazy_load=False,
+                plaid_mode=plaid_mode,
+            ), open(encrypted_model, "rb") as in_file, TensorDeserializer(
+                in_file,
+                device=device,
+                lazy_load=False,
+                plaid_mode=plaid_mode,
+                verify_hash=True,
+                encryption=decryption,
+            ) as deserialized:
+                check_deserialized(
+                    self,
+                    deserialized,
+                    model_name,
+                )
+                del deserialized
+            gc.collect()
+
+            # Ensure that it fails to load when not given a key
+            with self.subTest(
+                msg="Deserializing with a missing key"
+            ), self.assertRaises(serialization.CryptographyError), open(
+                encrypted_model, "rb"
+            ) as in_file, TensorDeserializer(
+                in_file,
+                device=device,
+                lazy_load=True,
+                encryption=None,
+            ) as deserialized:
+                self._test_first_key_negative(deserialized)
+                del deserialized
+            gc.collect()
+
+            # Ensure that it fails to load when given the wrong key
+            with self.subTest(
+                msg="Deserializing with an incorrect key"
+            ), self.assertRaises(serialization.CryptographyError), open(
+                encrypted_model, "rb"
+            ) as in_file, TensorDeserializer(
+                in_file,
+                device=device,
+                lazy_load=True,
+                encryption=incorrect_decryption,
+            ) as deserialized:
+                self._test_first_key_negative(deserialized)
+                del deserialized
+            gc.collect()
+
+
 class TestDeserialization(unittest.TestCase):
     _serialized_model_path: str
 
     @classmethod
     def setUpClass(cls):
-        serialized_model_path, sd = serialize_model(model_name, "cpu")
-        del sd
+        serialized_model_path = serialize_model(model_name, "cpu").filename
         cls._serialized_model_path = serialized_model_path
         gc.collect()
 
@@ -527,7 +749,7 @@ class TestVerification(unittest.TestCase):
 
     @classmethod
     def setUpClass(cls):
-        serialized_model_path = serialize_model(model_name, "cpu")[0]
+        serialized_model_path = serialize_model(model_name, "cpu").filename
         cls._serialized_model_path = serialized_model_path
         gc.collect()