Repository: pyca/cryptography

Issue:
[Feature request] AES-OCB
Old issues: https://github.com/pyca/cryptography/issues/1770 https://github.com/pyca/cryptography/issues/2968

The latter mentions patent issues. There is a license issued to OpenSSL specifically, so I wouldn't expect that to be a problem. However note that as of 2021 the patents have not been renewed, thereby allowing the IP to move into public domain: https://mailarchive.ietf.org/arch/msg/cfrg/qLTveWOdTJcLn4HP3ev-vrj05Vg/. Therefore there should be no more blockers to expose that particular API.


Discussion:
Interesting, I thought we had an open issue for this. Thanks for filing a new one. Yes, patent encumbrance is no longer an issue here and we can expose this. Now it's just a matter of doing the work 😄. Hopefully OpenSSL implements OCB3, although I haven't verified that yet.

Resolving patch:
diff --git a/src/cryptography/hazmat/backends/openssl/aead.py b/src/cryptography/hazmat/backends/openssl/aead.py
--- a/src/cryptography/hazmat/backends/openssl/aead.py
+++ b/src/cryptography/hazmat/backends/openssl/aead.py
@@ -14,6 +14,7 @@ def _aead_cipher_name(cipher):
     from cryptography.hazmat.primitives.ciphers.aead import (
         AESCCM,
         AESGCM,
+        AESOCB3,
         ChaCha20Poly1305,
     )
 
@@ -21,6 +22,8 @@ def _aead_cipher_name(cipher):
         return b"chacha20-poly1305"
     elif isinstance(cipher, AESCCM):
         return "aes-{}-ccm".format(len(cipher._key) * 8).encode("ascii")
+    elif isinstance(cipher, AESOCB3):
+        return "aes-{}-ocb".format(len(cipher._key) * 8).encode("ascii")
     else:
         assert isinstance(cipher, AESGCM)
         return "aes-{}-gcm".format(len(cipher._key) * 8).encode("ascii")
@@ -113,9 +116,13 @@ def _encrypt(backend, cipher, nonce, data, associated_data, tag_length):
     _process_aad(backend, ctx, associated_data)
     processed_data = _process_data(backend, ctx, data)
     outlen = backend._ffi.new("int *")
-    res = backend._lib.EVP_CipherFinal_ex(ctx, backend._ffi.NULL, outlen)
+    # All AEADs we support besides OCB are streaming so they return nothing
+    # in finalization. OCB can return up to (16 byte block - 1) bytes so
+    # we need a buffer here too.
+    buf = backend._ffi.new("unsigned char[]", 16)
+    res = backend._lib.EVP_CipherFinal_ex(ctx, buf, outlen)
     backend.openssl_assert(res != 0)
-    backend.openssl_assert(outlen[0] == 0)
+    processed_data += backend._ffi.buffer(buf, outlen[0])[:]
     tag_buf = backend._ffi.new("unsigned char[]", tag_length)
     res = backend._lib.EVP_CIPHER_CTX_ctrl(
         ctx, backend._lib.EVP_CTRL_AEAD_GET_TAG, tag_length, tag_buf
@@ -157,7 +164,10 @@ def _decrypt(backend, cipher, nonce, data, associated_data, tag_length):
     else:
         processed_data = _process_data(backend, ctx, data)
         outlen = backend._ffi.new("int *")
-        res = backend._lib.EVP_CipherFinal_ex(ctx, backend._ffi.NULL, outlen)
+        # OCB can return up to 15 bytes (16 byte block - 1) in finalization
+        buf = backend._ffi.new("unsigned char[]", 16)
+        res = backend._lib.EVP_CipherFinal_ex(ctx, buf, outlen)
+        processed_data += backend._ffi.buffer(buf, outlen[0])[:]
         if res == 0:
             backend._consume_errors()
             raise InvalidTag
diff --git a/src/cryptography/hazmat/primitives/ciphers/aead.py b/src/cryptography/hazmat/primitives/ciphers/aead.py
--- a/src/cryptography/hazmat/primitives/ciphers/aead.py
+++ b/src/cryptography/hazmat/primitives/ciphers/aead.py
@@ -220,3 +220,72 @@ def _check_params(
         utils._check_bytes("associated_data", associated_data)
         if len(nonce) < 8 or len(nonce) > 128:
             raise ValueError("Nonce must be between 8 and 128 bytes")
+
+
+class AESOCB3(object):
+    _MAX_SIZE = 2 ** 32
+
+    def __init__(self, key: bytes):
+        utils._check_byteslike("key", key)
+        if len(key) not in (16, 24, 32):
+            raise ValueError("AESOCB3 key must be 128, 192, or 256 bits.")
+
+        self._key = key
+
+        if not backend.aead_cipher_supported(self):
+            raise exceptions.UnsupportedAlgorithm(
+                "OCB3 is not supported by this version of OpenSSL",
+                exceptions._Reasons.UNSUPPORTED_CIPHER,
+            )
+
+    @classmethod
+    def generate_key(cls, bit_length: int) -> bytes:
+        if not isinstance(bit_length, int):
+            raise TypeError("bit_length must be an integer")
+
+        if bit_length not in (128, 192, 256):
+            raise ValueError("bit_length must be 128, 192, or 256")
+
+        return os.urandom(bit_length // 8)
+
+    def encrypt(
+        self,
+        nonce: bytes,
+        data: bytes,
+        associated_data: typing.Optional[bytes],
+    ) -> bytes:
+        if associated_data is None:
+            associated_data = b""
+
+        if len(data) > self._MAX_SIZE or len(associated_data) > self._MAX_SIZE:
+            # This is OverflowError to match what cffi would raise
+            raise OverflowError(
+                "Data or associated data too long. Max 2**32 bytes"
+            )
+
+        self._check_params(nonce, data, associated_data)
+        return aead._encrypt(backend, self, nonce, data, associated_data, 16)
+
+    def decrypt(
+        self,
+        nonce: bytes,
+        data: bytes,
+        associated_data: typing.Optional[bytes],
+    ) -> bytes:
+        if associated_data is None:
+            associated_data = b""
+
+        self._check_params(nonce, data, associated_data)
+        return aead._decrypt(backend, self, nonce, data, associated_data, 16)
+
+    def _check_params(
+        self,
+        nonce: bytes,
+        data: bytes,
+        associated_data: bytes,
+    ) -> None:
+        utils._check_byteslike("nonce", nonce)
+        utils._check_bytes("data", data)
+        utils._check_bytes("associated_data", associated_data)
+        if len(nonce) != 12:
+            raise ValueError("Nonce must be 96-bits (12 bytes)")


Test patch:
diff --git a/tests/hazmat/primitives/test_aead.py b/tests/hazmat/primitives/test_aead.py
--- a/tests/hazmat/primitives/test_aead.py
+++ b/tests/hazmat/primitives/test_aead.py
@@ -12,6 +12,7 @@
 from cryptography.hazmat.primitives.ciphers.aead import (
     AESCCM,
     AESGCM,
+    AESOCB3,
     ChaCha20Poly1305,
 )
 
@@ -466,3 +467,134 @@ def test_buffer_protocol(self, backend):
         assert ct2 == ct
         computed_pt2 = aesgcm2.decrypt(bytearray(nonce), ct2, ad)
         assert computed_pt2 == pt
+
+
+@pytest.mark.skipif(
+    _aead_supported(AESOCB3),
+    reason="Requires OpenSSL without AESOCB3 support",
+)
+def test_aesocb3_unsupported_on_older_openssl(backend):
+    with raises_unsupported_algorithm(_Reasons.UNSUPPORTED_CIPHER):
+        AESOCB3(AESOCB3.generate_key(128))
+
+
+@pytest.mark.skipif(
+    not _aead_supported(AESOCB3),
+    reason="Does not support AESOCB3",
+)
+class TestAESOCB3(object):
+    def test_data_too_large(self):
+        key = AESOCB3.generate_key(128)
+        aesocb3 = AESOCB3(key)
+        nonce = b"0" * 12
+
+        with pytest.raises(OverflowError):
+            aesocb3.encrypt(nonce, FakeData(), b"")
+
+        with pytest.raises(OverflowError):
+            aesocb3.encrypt(nonce, b"", FakeData())
+
+    def test_vectors(self, backend, subtests):
+        vectors = load_vectors_from_file(
+            os.path.join("ciphers", "AES", "OCB3", "rfc7253.txt"),
+            load_nist_vectors,
+        )
+        for vector in vectors:
+            with subtests.test():
+                nonce = binascii.unhexlify(vector["nonce"])
+                key = binascii.unhexlify(vector["key"])
+                aad = binascii.unhexlify(vector["aad"])
+                ct = binascii.unhexlify(vector["ciphertext"])
+                pt = binascii.unhexlify(vector.get("plaintext", b""))
+                aesocb3 = AESOCB3(key)
+                computed_ct = aesocb3.encrypt(nonce, pt, aad)
+                assert computed_ct == ct
+                computed_pt = aesocb3.decrypt(nonce, ct, aad)
+                assert computed_pt == pt
+
+    def test_vectors_invalid(self, backend, subtests):
+        vectors = load_vectors_from_file(
+            os.path.join("ciphers", "AES", "OCB3", "rfc7253.txt"),
+            load_nist_vectors,
+        )
+        for vector in vectors:
+            with subtests.test():
+                nonce = binascii.unhexlify(vector["nonce"])
+                key = binascii.unhexlify(vector["key"])
+                aad = binascii.unhexlify(vector["aad"])
+                ct = binascii.unhexlify(vector["ciphertext"])
+                aesocb3 = AESOCB3(key)
+                with pytest.raises(InvalidTag):
+                    badkey = AESOCB3(AESOCB3.generate_key(128))
+                    badkey.decrypt(nonce, ct, aad)
+                with pytest.raises(InvalidTag):
+                    aesocb3.decrypt(nonce, b"nonsense", aad)
+                with pytest.raises(InvalidTag):
+                    aesocb3.decrypt(b"\x00" * 12, ct, aad)
+                with pytest.raises(InvalidTag):
+                    aesocb3.decrypt(nonce, ct, b"nonsense")
+
+    @pytest.mark.parametrize(
+        ("nonce", "data", "associated_data"),
+        [
+            [object(), b"data", b""],
+            [b"0" * 12, object(), b""],
+            [b"0" * 12, b"data", object()],
+        ],
+    )
+    def test_params_not_bytes(self, nonce, data, associated_data, backend):
+        key = AESOCB3.generate_key(128)
+        aesocb3 = AESOCB3(key)
+        with pytest.raises(TypeError):
+            aesocb3.encrypt(nonce, data, associated_data)
+
+        with pytest.raises(TypeError):
+            aesocb3.decrypt(nonce, data, associated_data)
+
+    def test_invalid_nonce_length(self, backend):
+        key = AESOCB3.generate_key(128)
+        aesocb3 = AESOCB3(key)
+        with pytest.raises(ValueError):
+            aesocb3.encrypt(b"\x00" * 11, b"hi", None)
+        with pytest.raises(ValueError):
+            aesocb3.encrypt(b"\x00" * 13, b"hi", None)
+
+    def test_bad_key(self, backend):
+        with pytest.raises(TypeError):
+            AESOCB3(object())  # type:ignore[arg-type]
+
+        with pytest.raises(ValueError):
+            AESOCB3(b"0" * 31)
+
+    def test_bad_generate_key(self, backend):
+        with pytest.raises(TypeError):
+            AESOCB3.generate_key(object())  # type:ignore[arg-type]
+
+        with pytest.raises(ValueError):
+            AESOCB3.generate_key(129)
+
+    def test_associated_data_none_equal_to_empty_bytestring(self, backend):
+        key = AESOCB3.generate_key(128)
+        aesocb3 = AESOCB3(key)
+        nonce = os.urandom(12)
+        ct1 = aesocb3.encrypt(nonce, b"some_data", None)
+        ct2 = aesocb3.encrypt(nonce, b"some_data", b"")
+        assert ct1 == ct2
+        pt1 = aesocb3.decrypt(nonce, ct1, None)
+        pt2 = aesocb3.decrypt(nonce, ct2, b"")
+        assert pt1 == pt2
+
+    def test_buffer_protocol(self, backend):
+        key = AESOCB3.generate_key(128)
+        aesocb3 = AESOCB3(key)
+        pt = b"encrypt me"
+        ad = b"additional"
+        nonce = os.urandom(12)
+        ct = aesocb3.encrypt(nonce, pt, ad)
+        computed_pt = aesocb3.decrypt(nonce, ct, ad)
+        assert computed_pt == pt
+        aesocb3_ = AESOCB3(bytearray(key))
+        ct2 = aesocb3_.encrypt(bytearray(nonce), pt, ad)
+        assert ct2 == ct
+        computed_pt2 = aesocb3_.decrypt(bytearray(nonce), ct2, ad)
+        assert computed_pt2 == pt