blob: e407509f61615c09e1f30041daca8dddad291d09 [file] [edit]
# This file is dual licensed under the terms of the Apache License, Version
# 2.0, and the BSD License. See the LICENSE file in the root of this repository
# for complete details.
import os
import pytest
from cryptography.cobblestone import (
Cobblestone128Decryptor,
Cobblestone128Encryptor,
Cobblestone256Decryptor,
Cobblestone256Encryptor,
)
from cryptography.exceptions import AlreadyFinalized, InvalidTag
CHUNK_SIZE = 16 * 1024
TAG_LEN = 16
WIRE_CHUNK_SIZE = CHUNK_SIZE + TAG_LEN
SALT_LEN = 24
COMMITMENT_LEN = 32
HEADER_LEN = SALT_LEN + COMMITMENT_LEN
VARIANTS = [
pytest.param(
(Cobblestone128Encryptor, Cobblestone128Decryptor, 16),
id="cobblestone128",
),
pytest.param(
(Cobblestone256Encryptor, Cobblestone256Decryptor, 32),
id="cobblestone256",
),
]
def _encrypt_all(encryptor_cls, key: bytes, context: bytes, plaintext: bytes):
enc = encryptor_cls(key, context)
return enc.update(plaintext) + enc.finalize()
def _decrypt_all(decryptor_cls, key: bytes, context: bytes, ciphertext: bytes):
dec = decryptor_cls(key, context)
return dec.update(ciphertext) + dec.finalize()
MESSAGE_LENGTHS = [
0,
1,
57,
CHUNK_SIZE - 1,
CHUNK_SIZE,
CHUNK_SIZE + 1,
20 * 1024,
2 * CHUNK_SIZE - 1,
2 * CHUNK_SIZE,
2 * CHUNK_SIZE + 1,
3 * CHUNK_SIZE + 5000,
]
@pytest.mark.parametrize("variant", VARIANTS)
class TestCobblestone:
@pytest.mark.parametrize("length", MESSAGE_LENGTHS)
def test_round_trip(self, variant, length):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
context = b"test context"
plaintext = os.urandom(length)
ciphertext = _encrypt_all(encryptor_cls, key, context, plaintext)
n_chunks = length // CHUNK_SIZE + 1
assert len(ciphertext) == HEADER_LEN + length + n_chunks * TAG_LEN
assert (
_decrypt_all(decryptor_cls, key, context, ciphertext) == plaintext
)
@pytest.mark.parametrize("piece_size", [1, 57, 1024, 16384, 16400])
def test_streaming(self, variant, piece_size):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
context = b""
plaintext = os.urandom(2 * CHUNK_SIZE + 12345)
enc = encryptor_cls(key, context)
ciphertext = b""
for i in range(0, len(plaintext), piece_size):
ciphertext += enc.update(plaintext[i : i + piece_size])
ciphertext += enc.finalize()
# The result matches a single-shot encryption's structure, and
# decrypts to the plaintext regardless of how the ciphertext is
# split up.
assert len(ciphertext) == HEADER_LEN + len(plaintext) + 3 * TAG_LEN
dec = decryptor_cls(key, context)
decrypted = b""
for i in range(0, len(ciphertext), piece_size):
decrypted += dec.update(ciphertext[i : i + piece_size])
decrypted += dec.finalize()
assert decrypted == plaintext
def test_empty_message(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
enc = encryptor_cls(key, b"ctx")
ciphertext = enc.finalize()
assert len(ciphertext) == HEADER_LEN + TAG_LEN
assert _decrypt_all(decryptor_cls, key, b"ctx", ciphertext) == b""
def test_update_with_empty_data_emits_header(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
enc = encryptor_cls(key, b"")
header = enc.update(b"")
assert len(header) == HEADER_LEN
assert enc.update(b"") == b""
ciphertext = header + enc.finalize()
assert _decrypt_all(decryptor_cls, key, b"", ciphertext) == b""
def test_exact_chunk_boundary_has_empty_final_chunk(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
plaintext = os.urandom(CHUNK_SIZE)
ciphertext = _encrypt_all(encryptor_cls, key, b"", plaintext)
# One full chunk plus an empty final chunk.
assert len(ciphertext) == HEADER_LEN + WIRE_CHUNK_SIZE + TAG_LEN
assert _decrypt_all(decryptor_cls, key, b"", ciphertext) == plaintext
def test_decrypter_streams_plaintext_incrementally(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
plaintext = os.urandom(3 * CHUNK_SIZE)
ciphertext = _encrypt_all(encryptor_cls, key, b"", plaintext)
dec = decryptor_cls(key, b"")
out = dec.update(ciphertext[: HEADER_LEN + WIRE_CHUNK_SIZE])
assert out == plaintext[:CHUNK_SIZE]
out = dec.update(ciphertext[HEADER_LEN + WIRE_CHUNK_SIZE :])
assert out == plaintext[CHUNK_SIZE:]
assert dec.finalize() == b""
def test_wrong_key(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
ciphertext = _encrypt_all(encryptor_cls, key, b"", b"message")
dec = decryptor_cls(encryptor_cls.generate_key(), b"")
with pytest.raises(InvalidTag):
dec.update(ciphertext)
def test_wrong_context(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
ciphertext = _encrypt_all(encryptor_cls, key, b"context a", b"msg")
dec = decryptor_cls(key, b"context b")
with pytest.raises(InvalidTag):
dec.update(ciphertext)
@pytest.mark.parametrize(
"position",
[
0, # salt
SALT_LEN, # commitment
HEADER_LEN, # first chunk ciphertext
HEADER_LEN + WIRE_CHUNK_SIZE - 1, # first chunk tag
HEADER_LEN + WIRE_CHUNK_SIZE + 3, # final chunk
],
)
def test_tampering_detected(self, variant, position):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
plaintext = os.urandom(CHUNK_SIZE + 100)
ciphertext = bytearray(
_encrypt_all(encryptor_cls, key, b"", plaintext)
)
ciphertext[position] ^= 1
dec = decryptor_cls(key, b"")
with pytest.raises(InvalidTag):
dec.update(bytes(ciphertext))
dec.finalize()
def test_swapped_chunks_detected(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
plaintext = os.urandom(2 * CHUNK_SIZE + 100)
ciphertext = _encrypt_all(encryptor_cls, key, b"", plaintext)
chunk0_start = HEADER_LEN
chunk1_start = HEADER_LEN + WIRE_CHUNK_SIZE
chunk2_start = HEADER_LEN + 2 * WIRE_CHUNK_SIZE
swapped = (
ciphertext[:HEADER_LEN]
+ ciphertext[chunk1_start:chunk2_start]
+ ciphertext[chunk0_start:chunk1_start]
+ ciphertext[chunk2_start:]
)
dec = decryptor_cls(key, b"")
with pytest.raises(InvalidTag):
dec.update(swapped)
@pytest.mark.parametrize(
"length",
[
0,
1,
HEADER_LEN - 1,
HEADER_LEN, # no final chunk at all
HEADER_LEN + TAG_LEN - 1, # final chunk shorter than its tag
HEADER_LEN + WIRE_CHUNK_SIZE, # ends on a chunk boundary
HEADER_LEN + WIRE_CHUNK_SIZE + TAG_LEN - 1,
],
)
def test_truncation_detected(self, variant, length):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
plaintext = os.urandom(CHUNK_SIZE + 100)
ciphertext = _encrypt_all(encryptor_cls, key, b"", plaintext)
dec = decryptor_cls(key, b"")
with pytest.raises(InvalidTag):
dec.update(ciphertext[:length])
dec.finalize()
def test_extension_detected(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
ciphertext = _encrypt_all(encryptor_cls, key, b"", b"message")
dec = decryptor_cls(key, b"")
with pytest.raises(InvalidTag):
dec.update(ciphertext + b"extra garbage bytes!")
dec.finalize()
def test_ciphertexts_are_randomized(self, variant):
encryptor_cls, _, _ = variant
key = encryptor_cls.generate_key()
assert _encrypt_all(encryptor_cls, key, b"", b"data") != _encrypt_all(
encryptor_cls, key, b"", b"data"
)
def test_update_into(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
plaintext = os.urandom(CHUNK_SIZE + 100)
enc = encryptor_cls(key, b"")
buf = bytearray(HEADER_LEN + 2 * WIRE_CHUNK_SIZE)
n = enc.update_into(plaintext, buf)
assert n == HEADER_LEN + WIRE_CHUNK_SIZE
ciphertext = bytes(buf[:n]) + enc.finalize()
assert _decrypt_all(decryptor_cls, key, b"", ciphertext) == plaintext
dec = decryptor_cls(key, b"")
out = bytearray(2 * CHUNK_SIZE)
n = dec.update_into(ciphertext, out)
assert n == CHUNK_SIZE
assert bytes(out[:n]) + dec.finalize() == plaintext
def test_update_into_accepts_larger_buffer(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
enc = encryptor_cls(key, b"")
buf = bytearray(10 * WIRE_CHUNK_SIZE)
n = enc.update_into(b"abc", buf)
assert n == HEADER_LEN
ciphertext = bytes(buf[:n]) + enc.finalize()
assert _decrypt_all(decryptor_cls, key, b"", ciphertext) == b"abc"
def test_update_into_buffer_too_small(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
enc = encryptor_cls(key, b"")
with pytest.raises(ValueError, match="buffer must be at least"):
enc.update_into(b"abc", bytearray(HEADER_LEN - 1))
# The context remains usable after the failed call.
ciphertext = enc.update(b"abc") + enc.finalize()
dec = decryptor_cls(key, b"")
with pytest.raises(ValueError, match="buffer must be at least"):
dec.update_into(
ciphertext + bytes(WIRE_CHUNK_SIZE), bytearray(CHUNK_SIZE - 1)
)
assert dec.update(ciphertext) == b""
assert dec.finalize() == b"abc"
def test_update_into_zero_output(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
dec = decryptor_cls(key, b"")
assert dec.update_into(b"", bytearray(0)) == 0
def test_use_after_finalize(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
enc = encryptor_cls(key, b"")
ciphertext = enc.update(b"data") + enc.finalize()
with pytest.raises(AlreadyFinalized):
enc.update(b"more")
with pytest.raises(AlreadyFinalized):
enc.update_into(b"more", bytearray(WIRE_CHUNK_SIZE))
with pytest.raises(AlreadyFinalized):
enc.finalize()
dec = decryptor_cls(key, b"")
dec.update(ciphertext)
dec.finalize()
with pytest.raises(AlreadyFinalized):
dec.update(b"more")
with pytest.raises(AlreadyFinalized):
dec.update_into(b"more", bytearray(WIRE_CHUNK_SIZE))
with pytest.raises(AlreadyFinalized):
dec.finalize()
def test_decryptor_unusable_after_invalid_tag(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
ciphertext = bytearray(_encrypt_all(encryptor_cls, key, b"", b"data"))
ciphertext[-1] ^= 1
dec = decryptor_cls(key, b"")
dec.update(bytes(ciphertext))
with pytest.raises(InvalidTag):
dec.finalize()
# All subsequent operations fail.
with pytest.raises(AlreadyFinalized):
dec.update(b"")
with pytest.raises(AlreadyFinalized):
dec.finalize()
def test_decryptor_update_into_unusable_after_invalid_tag(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
plaintext = os.urandom(CHUNK_SIZE + 100)
ciphertext = bytearray(
_encrypt_all(encryptor_cls, key, b"", plaintext)
)
ciphertext[HEADER_LEN] ^= 1 # corrupt the first full chunk
dec = decryptor_cls(key, b"")
buf = bytearray(2 * CHUNK_SIZE)
with pytest.raises(InvalidTag):
dec.update_into(bytes(ciphertext), buf)
# All subsequent operations fail.
with pytest.raises(AlreadyFinalized):
dec.update_into(b"", buf)
with pytest.raises(AlreadyFinalized):
dec.finalize()
def test_finalize_only_decryptor_rejects_empty_stream(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
dec = decryptor_cls(key, b"")
with pytest.raises(InvalidTag):
dec.finalize()
def test_generate_key(self, variant):
encryptor_cls, _, key_len = variant
key = encryptor_cls.generate_key()
assert isinstance(key, bytes)
assert len(key) == key_len
assert encryptor_cls.generate_key() != encryptor_cls.generate_key()
def test_invalid_key_size(self, variant):
encryptor_cls, decryptor_cls, key_len = variant
for length in [0, key_len - 1, key_len + 1, 64]:
with pytest.raises(ValueError):
encryptor_cls(b"\x00" * length, b"")
with pytest.raises(ValueError):
decryptor_cls(b"\x00" * length, b"")
def test_invalid_types(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = encryptor_cls.generate_key()
with pytest.raises(TypeError):
encryptor_cls("not bytes", b"")
with pytest.raises(TypeError):
encryptor_cls(key, "not bytes")
with pytest.raises(TypeError):
decryptor_cls("not bytes", b"")
enc = encryptor_cls(key, b"")
with pytest.raises(TypeError):
enc.update("not bytes")
with pytest.raises(TypeError):
enc.update_into(b"", b"immutable")
def test_accepts_buffers(self, variant):
encryptor_cls, decryptor_cls, _ = variant
key = bytearray(encryptor_cls.generate_key())
plaintext = os.urandom(1000)
enc = encryptor_cls(key, memoryview(b"ctx"))
ciphertext = enc.update(memoryview(plaintext)) + enc.finalize()
dec = decryptor_cls(memoryview(bytes(key)), bytearray(b"ctx"))
assert dec.update(bytearray(ciphertext)) + dec.finalize() == plaintext
class TestVariantsAreDistinct:
def test_key_sizes_differ(self):
assert len(Cobblestone128Encryptor.generate_key()) == 16
assert len(Cobblestone256Encryptor.generate_key()) == 32
def test_cross_variant_key_sizes_rejected(self):
key128 = Cobblestone128Encryptor.generate_key()
key256 = Cobblestone256Encryptor.generate_key()
with pytest.raises(ValueError, match="key must be 16 bytes"):
Cobblestone128Encryptor(key256, b"")
with pytest.raises(ValueError, match="key must be 32 bytes"):
Cobblestone256Decryptor(key128, b"")