This is an adaptation of the exchange I had on the Python discord server with someone who answered the questions:

is the c_char_p thus created a second pointer ? If so, what happens to the pointer allocated by the C ?

Yes, it is. The c_char_p is a PyObject* pointer that lives on the heap, and it stores the original C char* pointer inside it.

how do we free the pointer returned from the C ?

Pass the c_char_p to free() using ctypes

is there a difference of behavior between how c_void_p and c_char_p are handled as restype by the GC ?

No, in both cases, when the c_void_p or c_char_p is garbage collected, it does nothing, ie, it does not free the original C object that was pointed to.

What is the correct way to "give ownership" of a memory pointer allocated in Python to the C ?

That's not normally something you'd do - memory created by Python is usually freed by Python, memory created by C is usually freed by C. If you need to pass something from one domain to the other, it's best to copy it. It's possible to make the C side keep a Python reference to the Python object by using the Python C API, but that's probably not the best idea.

is a string created by create_string_buffer also Python-GC'ed ?

In this case, when the Python object is GC'd, it also frees the C memory. A c_char_p knows whether it owns the memory that it points to or doesn't, and when it's GC'd it frees the C memory if it owns it.
========
Since my (I = asker) goal is to have as few (no) reference to ctypes types for the Python end-user, given the answers above, I would do the following:

when I malloc a char* in the C in a function char* do_c_func(), I return it as a c_char_p restype in Python. In my Python, I have a wrapper function do_py_func() that calls do_c_func(), receives this c_char_p called my_c_char_p, and stores its contents in a Python string, through some sort of s = str(my_c_char_p). I then call ctypes.free(my_c_char_p): this frees the malloced char*, and the end of the function decrementing the last reference counter, this eventually garbage collects my_c_char_p.

I create a conversion function python_str_to_c_str(py_str) which takes a normal python string, and returns stdc.strdup(py_str.encode('utf-8')) (with stdc.strdup.restype = c_char_p), thereby providing a new char* to the C (which Python will recognize as having been created in the C). I use this conversion function anywhere I need to pass a string from the Python to the C. This makes it so the buffer created by my_string.encode('utf-8')), and the c_char_p returned by stdc.strdup, are both garbage-collected by the Python, but the char* itself is not.
I'm trying to fix leaks in a ctypes-based Python binding over a C library. I'm confused as to the behavior of memory management of strings and other pointers when working with ctypes.
There are, to my knowledge, 3 cases I need to solve, basically:
A) Creating memory in C and returning a c_char_p as the result of the function to the Python.
B) Creating a string in the Python, and passing it as a char const* parameter of a C function, and not freeing it in the C: this is the only one I've solved confidently: I can just do my_str.encode("utf-8") into a c_char_p, and the Python Alloc/GC handles both the allocation and deallocation.
C) Creating memory in Python, and storing it in the C (for example as a char* field within a struct), to be deallocated later (with a function from the C library).
My questions:
A)

is the c_char_p thus created a second pointer ? If so, what happens to the pointer allocated by the C ?
how do we free the pointer returned from the C ?
is there a difference of behavior between how c_void_p and c_char_p are handled as restype by the GC ?

C)

What is the correct way to "give ownership" of a memory pointer allocated in Python to the C ? .encode() clearly makes a Python object that's GC'ed.
is a string created by create_string_buffer also Python-GC'ed ?
Memory management and removal of leaks, in C and Python with ctypes, using the example of strings