Grokking the basics of BlockChain with Python
By default mypy doesn't test much. This is because a large part of its philosophy is to allow dynamic and statically typed code at the same time. This is so migrating to mypy is easier and less daunting. Having thousands of errors when you start to port your legacy app is likely to scare a fair few mortals away.

Please use the --strict flag to have typed Python, rather than hybrid Python.

When you use the flag and you type all the functions and methods, you'll notice that there's an issue with Node.prior_node. Currently it's assigned the type Node, but we know that's a lie because we have if prior_node is None.
I personally use --ignore-missing-imports rather than ignoring each import, as they quickly build up over time.
Your __repr__ are non standard.


  Called by the repr() built-in function to compute the “official” string representation of an object. If at all possible, this should look like a valid Python expression that could be used to recreate an object with the same value (given an appropriate environment). If this is not possible, a string of the form &lt;...some useful description...&gt; should be returned.


You probably want to be using __str__.
I find it a little confusing that init_vector is being assigned two different things. It would make more sense if you pass in an empty bytes to _ensure_byte_length.

prior_hash = b'' if prior_node is None else prior_node.hash
init_vector = _ensure_byte_length(prior_hash, 16)


You could change the ternary into a getattr.

prior_hash = getattr(prior_hash, 'hash', b'')

I would change Node to a dataclass. Why write code when you can just not?

This would require moving the hash generation into a class method.
I would define an __iter__ method on the node so that we can easily traverse the chain from any node.

This makes the __len__ method of Chain really simple and clean.
I'd rename _ensure_byte_length to _pad. The function has two jobs, pad is well known and allows us to have a much shorter function name.
_ensure_byte_length doesn't need the extra bytes call.
The method Chain.add_node is un-Pythonic. In Python it's standard to return nothing from a function with mutations.


from __future__ import annotations

import dataclasses
from typing import Optional, Iterator

from oscrypto.symmetric import aes_cbc_pkcs7_encrypt as encrypt


@dataclasses.dataclass
class Node:
    prev_node: Optional[Node]
    data: bytes
    hash: bytes

    @classmethod
    def build(cls, key: bytes, data: bytes, prev_node: Optional[Node] = None) -&gt; Node:
        prev_hash = b"" if prev_node is None else prev_node.hash
        hash = encrypt(_pad(key, 32), data, _pad(prev_hash, 16))[1]
        return cls(prev_node, data, hash)

    def __iter__(self) -&gt; Iterator[Node]:
        node: Optional[Node] = self
        while node is not None:
            yield node
            node = node.prev_node


def _pad(bytes_: bytes, length: int) -&gt; bytes:
    return bytes_.ljust(length, b"\x00")[:length]


@dataclasses.dataclass
class Chain:
    node: Node

    def add_node(self, key: bytes, data: bytes) -&gt; None:
        self.node = Node.build(key, data, self.node)

    def __len__(self) -&gt; int:
        return sum(1 for _ in self.node)


def main() -&gt; None:
    chain = Chain(Node.build(b"the key", b"here is a bit of data"))
    chain.add_node(b"P@$$w0rd", b"and here is a bit more data")
    chain.add_node(b"hunter2", b"and finally here is some more")
    print(chain)


if __name__ == "__main__":
    main()