Regarding the "not supposed to happen" in the line def f2(key: bytes) -- that is a misconception.  That key: bytes syntax is called an "annotation", as is the -> return_type syntax.  It's there as a notational prompt for the programmer and is not enforced by the interpreter.  IIRC the idea was that third-party tools could use it to autocheck the code.
This behavior is incorrect as complete isolation between functions is
the norm - supported by the fact that there is memory isolation
between variables of functions.
That is not correct.  There is some scoping of variables that goes on when you make a function call, but not when you pass the variable as part of the function call.  C pushes data values onto the stack for use by its functions, but Python passes the references to its data objects -- the equivalent of a pointer in C.
The "problem" with your code is that you're not passing bytesarray data to your function, you're passing a reference to the bytesarray object.  This can be demonstrated with some minor changes to your code:
k = bytearray(b"0123")
def f1(key: bytearray):
    key[0] = 48 + 4
    return
def f2(key: bytes):
    key[0] = 48 + 4
    return
def f3(key: bytearray):
    for i in key:
        i = 0
    return
def f4(key: bytearray):
    for i in range(len(key)):
        key[i] = 0
    return
def whatsmyobjectid(key):
    print(key, id(key))
print(k, id(k))
whatsmyobjectid(k)
f4(k)
print(k, id(k))
The output is:
bytearray(b'0123') 140407680541424
bytearray(b'0123') 140407680541424
bytearray(b'\x00\x00\x00\x00') 140407680541424
As you can see, the object ID of key inside the function is the same as k before making the call and after making the call.
If you only want the data passed to the function and not a reference to the object, you'd have to do something like this:
f4(bytesarray(k))
This can be illustrated by changing the bottom of your code to:
print(k, id(k))
whatsmyobjectid(k)
f4(bytearray(k))
print(k, id(k))
f4(k)
print(k, id(k))
The output is:
bytearray(b'0123') 140046806483824
bytearray(b'0123') 140046806483824
bytearray(b'0123') 140046806483824
bytearray(b'\x00\x00\x00\x00') 140046806483824
As you can see, if you pass the variable using f4(bytearray(k)) then the original bytesarray object k is left unaltered, but when you pass just k it's changed by the function.
I should probably address your f3() function:
def f3(key: bytearray):
    for i in key:
        i = 0
    return
Let's analyze that for a moment:
So you're taking in the bytesarray object key.  for i in key returns the decimal value of each byte in that array in i, yes, but then you're just changing i to 0 before having the next value of key fetched back into i -- you're not doing anything with it.  If you were to change that i = 0 to print(i) you'd see that the output is:
48
49
50
51
which you already know are the decimal ASCII digits for zero through three.
I think what you were intending to do in f3() was to change each byte in the bytesarray to zero.  If you were to change that function to:
def f3(key: bytearray):
    for i in range(len(key)):
        key[i] = 0
    return
you'll see that it does, indeed, have the same effect as f4().
Just to further demonstrate the scoping of variables:
x = 1
print(x)
def xis17():
    x = 17
    print(x)
xis17()
print(x)
Output is:
1
17
1
You can see that you can reuse x inside of the function without affecting the parent's x.
Scoping sometimes does get hinkily inconsistent to some degree in Python.  For example, this is legal:
x = 1
print(x)
def xis17():
    print(x)
xis17()
print(x)
and will print the value of the parent's x, but the following will throw an UnboundLocalError exception -- on the print(), not the increment:
x = 1
print(x)
def xis17():
    print(x)
    x+=1
xis17()
print(x)