How to: use custom / self-signed certificates with Requests in Python

In a previous post, I explained how to configure Azurite to use a self-signed certificate to enable OAuth authentication. One challenge with this method is that the Azure Python SDK will refuse to connect to azurite, reporting errors such as:

azure.core.exceptions.ServiceRequestError: [SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: unable to get local issuer certificate (_ssl.c:1006)

This is because the the Azure SDK uses the Requests library, which in turn uses Certifi as its source of root certificates. Certifi provides a regularly updated bundle of the Mozilla root trust store, and our self-signed custom certificate obviously isn’t in Mozilla’s trust list!

Making Requests trust our self-signed certificate

You can get around this by setting the REQUESTS_CA_BUNDLE or CA_BUNDLE environment variables which tells Certifi to ignore its inbuilt certificate bundles, but if we just point that at our self-signed root certificate then we lose access to the rest of the trust store, which makes connecting to public APIs difficult.

One option is to copy the certifi trust store to a location in our workspace and inject our certificate, then set the environment variables to point to this new store, but that’s more config to manage, and this is just local development – we dont need the extra hassle.

Injecting our certificate in to Certifi’s trust store

So my preferred method is to inject my self-signed certificate in to Certifi’s trust store directly, and I created the following bash script to do this. First it checks you’re in a virtual environment, then checks that certifi is installed (it uses pip for this, but this will work alongside other package managers). Finally, it extracts the first line of the base64 encoded certificate and checks to make sure it isn’t already in the certificate bundle before injecting it along with some metadata to help you figure out where it came from.

#!/bin/bash
# Function to log errors and exit
log_error() {
    echo "❌ Error: $1"
    exit 1
}
# Check if custom certificate path is provided
CUSTOM_CERT="$1"
if [ -z "$CUSTOM_CERT" ]; then
    log_error "Please provide the path to the custom certificate as an argument."
fi
# Check if the custom certificate file exists
if [ ! -f "$CUSTOM_CERT" ]; then
    log_error "Custom certificate file not found at $CUSTOM_CERT."
fi
# Check if in a virtual environment
if python -c 'import sys; sys.exit(0 if sys.prefix != sys.base_prefix else 1)'; then
    echo "✅ Virtual environment detected: $(python -c 'import sys; sys.stdout.write(sys.prefix)')"
else
    log_error "Not in a virtual environment. Please activate a virtual environment before running the script."
fi
# Check if certifi is installed
if ! python3 -m pip show certifi >/dev/null 2>&1; then
    log_error "certifi is not installed. Please install it using 'python3 -m pip install certifi'."
fi
# Get the path to the certifi certificate file
CERTIFI_CERT=$(python3 -m certifi) || log_error "Failed to get certifi certificate path."
if [ ! -f "$CERTIFI_CERT" ]; then
    log_error "Certifi certificate file not found at $CERTIFI_CERT."
fi
# Extract the first line of the public key from the custom certificate as the unique identifier
UNIQUE_IDENTIFIER=$(awk '/BEGIN CERTIFICATE/{getline; print}' "$CUSTOM_CERT")
echo "Custom certificate: $CUSTOM_CERT"
echo "Certifi certificate file: $CERTIFI_CERT"
echo "Unique identifier: $UNIQUE_IDENTIFIER"
# Check if the custom certificate is already in certifi's certificate file, and append if not
if grep -q "$UNIQUE_IDENTIFIER" "$CERTIFI_CERT"; then
    echo "✅ Custom certificate is already present in certifi's certificate file."
else
    echo "Appending custom certificate..."
    # Extract additional certificate information
    ISSUER=$(openssl x509 -in "$CUSTOM_CERT" -noout -issuer | sed 's/issuer=//')
    SUBJECT=$(openssl x509 -in "$CUSTOM_CERT" -noout -subject | sed 's/subject=//')
    LABEL=$(openssl x509 -in "$CUSTOM_CERT" -noout -subject | awk -F 'CN=' '{print $2}' | cut -d, -f1)
    SERIAL=$(openssl x509 -in "$CUSTOM_CERT" -noout -serial | sed 's/serial=//')
    MD5_FINGERPRINT=$(openssl x509 -in "$CUSTOM_CERT" -noout -fingerprint -md5 | sed 's/MD5 Fingerprint=//')
    SHA1_FINGERPRINT=$(openssl x509 -in "$CUSTOM_CERT" -noout -fingerprint -sha1 | sed 's/SHA1 Fingerprint=//')
    SHA256_FINGERPRINT=$(openssl x509 -in "$CUSTOM_CERT" -noout -fingerprint -sha256 | sed 's/SHA256 Fingerprint=//')
    TIMESTAMP=$(date -u +"%Y-%m-%d %H:%M:%S UTC")
    # Append the certificate with metadata
    {
        echo
        echo "# Certificate added by script at $TIMESTAMP"
        echo "# Issuer: $ISSUER"
        echo "# Subject: $SUBJECT"
        echo "# Label: \"$LABEL\""
        echo "# Serial: $SERIAL"
        echo "# MD5 Fingerprint: $MD5_FINGERPRINT"
        echo "# SHA1 Fingerprint: $SHA1_FINGERPRINT"
        echo "# SHA256 Fingerprint: $SHA256_FINGERPRINT"
        cat "$CUSTOM_CERT"
    } >> "$CERTIFI_CERT" || log_error "Failed to append custom certificate to certifi."
    echo "✅ Custom certificate appended to certifi's certificate file."
fi

Drawback – certifi is updated

This approach (or either approach really) has one major drawback – it needs to be run every time Certifi is updated, which is roughly monthly. So, i decided to run the script as a launch task in VSCode so that I can attach it to the “debug” tasks that are already embedded in my workflow. While i was at it, i chained a poetry install task to ensure that my dependencies are fully installed. I added the task in VSCode’s tasks.json to run the script after installing the packages (also note i’ve replaced pip with poetry in the package task):

{
  "version": "2.0.0",
  "tasks": [
    {
      "label": "func host start",
      "type": "func",
      "command": "host start --verbose --script-root ${workspaceFolder}/app",
      "options": {
        "env": {
          "PYTHON_ENABLE_DEBUG_LOGGING": "1"
        }
      },
      "problemMatcher": "$func-python-watch",
      "isBackground": true,
      "dependsOn": "add minica cert to certifi"
    },
    {
      "label": "add minica cert to certifi",
      "type": "shell",
      "osx": {
        "command": "${workspaceFolder}/azurite/add_minica_cert_to_certifi.sh ${workspaceFolder}/azurite/minica.pem"
      },
      "linux": {
        "command": "${workspaceFolder}/azurite/add_minica_cert_to_certifi.sh ${workspaceFolder}/azurite/minica.pem"
      },
      "problemMatcher": [],
      "dependsOn": "pip install (functions)"
    },
    {
      "label": "pip install (functions)",
      "type": "shell",
      "osx": {
        "command": "poetry install --with dev --no-interaction --sync --no-root"
      },
      "linux": {
        "command": "poetry install --with dev --no-interaction --sync --no-root"
      },
      "problemMatcher": []
    },
  ]
}

Finally, i added it as a preLaunchTask in launch.json so that it runs when i manually debug too:

{
  // Use IntelliSense to learn about possible attributes.
  // Hover to view descriptions of existing attributes.
  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
  "version": "0.2.0",
  "configurations": [
    {
      "name": "Python: Debug Tests",
      "type": "debugpy",
      "request": "launch",
      "program": "${file}",
      "purpose": [
        "debug-test"
      ],
      "console": "integratedTerminal",
      "justMyCode": true,
      "stopOnEntry": false,      
      "env": {
        "PYTEST_ADDOPTS": "--no-cov",
        "PYTHONPATH": "${workspaceFolder}",
        "LOG_LEVEL": "DEBUG"
      },
      "preLaunchTask": "add minica cert to certifi"
    },
    {
      "name": "Python Debugger: Current File",
      "type": "debugpy",
      "request": "launch",
      "program": "${file}",
      "console": "integratedTerminal",
      "preLaunchTask": "add minica cert to certifi"
    },
    {
      "name": "Attach to Python Functions",
      "type": "debugpy",
      "request": "attach",
      "connect": {
        "host": "localhost",
        "port": 9091
      },
      "preLaunchTask": "func host start"
    }
  ]
}

Conclusion

By injecting our self-signed certificate in to Certifi’s trust store, we’ve created a solution that:

  1. Maintains the integrity of the existing trust store
  2. Avoids the need for environment variable management
  3. Integrates seamlessly with our development workflow in VSCode

Although this approach requires us to run the script whenever Certifi is updated, by using a VSCode task configuration we can ensure that this happens automatically, and link it to our existing debug tasks.

Remember, this method is intended for development purposes. In production environments, always use properly signed certificates from trusted certificate authorities.

How to: use Azurite with self-generated certificates for HTTPS in a Codespace or Devcontainer

I’ve been using Azurite to simulate Azure storage for my development. If you’re not familiar with it, Azurite is a local storage emulator for Azure Storage, and you can read my other post about how i’ve set up my devcontainer configuration to run Azurite as a service container. As my deployed code is using an Azure Managed Identity, I wanted ensure my development environment was consistent with this and also uses Azure DefaultAzureCredential credential provider class. In this post, i will talk through the steps required to switch from using a connection string (with a well-known account and key) to using OAuth and HTTPS, helping to increase feature parity between development and production, reducing the chances of mistakes.

There are essentially 5 steps:

  1. Create a local Certificate Authority
  2. Configure Azurite to use the certificate (and enable OAuth with basic checking)
  3. Configure the devcontainer to trust the certificates
  4. Configure the local credential for OAuth
  5. Configure Azure Storage Explorer to trust the minica root certificate

Obviously, this is only useful for development, and you shouldn’t use this to secure services running directly on the internet.

Create a local Certificate Authority

The first hurdle is to set up a CA, and issue a certificate for Azurite to use. By far the simplest way is to use minica – a simple CA which generates a root certificate and any number of other certificates. The other tool i found is mkcert but i didn’t try it.

We could set this up so that it’s built every time we rebuild the devcontainer, but the minica certificate is valid for over 2 years, so it’s probably not worth it, so instead just install minica on your local and generate the certificates which we can then copy to the repo. There are installation instructions on the minica repo – I did this on my Mac:

~/Downloads > brew install minica
==> Downloading https://ghcr.io/v2/homebrew/core/minica/manifests/1.1.0
Already downloaded: /Users/rob/Library/Caches/Homebrew/downloads/291ff83573a0a9e0a7033accd18d58fcf701211c2b7b63a31e49c36fabc0cb5f--minica-1.1.0.bottle_manifest.json
==> Fetching minica
==> Downloading https://ghcr.io/v2/homebrew/core/minica/blobs/sha256:dc8955ffd5c34b8eaedbc556e71188ec55c2a01e76c26f853aeb0038c7ac2426
############################################################################################################################################################################################################### 100.0%
==> Pouring minica--1.1.0.arm64_sonoma.bottle.tar.gz
🍺  /opt/homebrew/Cellar/minica/1.1.0: 6 files, 4.3MB
==> Running `brew cleanup minica`...
Disable this behaviour by setting HOMEBREW_NO_INSTALL_CLEANUP.
Hide these hints with HOMEBREW_NO_ENV_HINTS (see `man brew`).
~/Downloads > mkdir azurite-certs
~/Downloads > cd azurite-certs
~/Downloads/azurite-certs > minica -ip-addresses 127.0.0.1
~/Downloads/azurite-certs > tree
.
├── 127.0.0.1
│   ├── cert.pem
│   └── key.pem
├── minica-key.pem
└── minica.pem
2 directories, 4 files

The minica-key.pem and minica.pem files are the CA’s private and public keys respectively. The 127.0.0.1 folder contains the private key and certificate for the hostname 127.0.0.1.

Be sure to use the argument -ip-addresses and not -domains – Node requires that IP addresses are present in the Subject Alternative Name (SAN) field of certificates. If you accidentially use the -domains option, you’ll get an ERR_TLS_CERT_ALTNAME_INVALID error when you try to connect from Azure Storage Explorer.

Examining the certificate, it looks like this:

~/Downloads/azurite-certs/127.0.0.1 > openssl x509 -in cert.pem -text -noout
Certificate:
    Data:
        Version: 3 (0x2)
        Serial Number: 4677514164283179045 (0x40e9de5991f50025)
        Signature Algorithm: ecdsa-with-SHA384
        Issuer: CN=minica root ca 190f9e
        Validity
            Not Before: Oct  6 10:03:35 2024 GMT
            Not After : Nov  5 11:03:35 2026 GMT
        Subject: CN=127.0.0.1
        Subject Public Key Info:
            Public Key Algorithm: id-ecPublicKey
                Public-Key: (384 bit)
                pub:
                    04:...:eb
                ASN1 OID: secp384r1
                NIST CURVE: P-384
        X509v3 extensions:
            X509v3 Key Usage: critical
                Digital Signature, Key Encipherment
            X509v3 Extended Key Usage: 
                TLS Web Server Authentication, TLS Web Client Authentication
            X509v3 Basic Constraints: critical
                CA:FALSE
            X509v3 Authority Key Identifier: 
                F1:D0:94:63:AA:37:F6:EF:CF:5F:CD:83:80:2C:95:D0:76:6C:2A:07
            X509v3 Subject Alternative Name: 
                IP Address:127.0.0.1
    Signature Algorithm: ecdsa-with-SHA384
    Signature Value:
        30:...:50

I copied the entire azurite-certs folder to the .devcontainer folder of my project, and renamed the folder 127.0.0.1 to certs (as i found i couldn’t mount the originally named folder into the container).

Configure Azurite to use the certificate (and enable OAuth with basic checking)

This is relatively easy. Azurite only really needs to be told the path to the certificate and private key from the 127.0.0.1 folder. To do this, we can mount the folder in to the container, and pass the path to Azurite in the command:

services:
  devcontainer:
    ...
  azurite:
    image: mcr.microsoft.com/azure-storage/azurite
    ports:
      - "127.0.0.1:10000:10000"
      - "127.0.0.1:10001:10001"
      - "127.0.0.1:10002:10002"
    command: >
      azurite
      --blobHost 0.0.0.0
      --queueHost 0.0.0.0
      --tableHost 0.0.0.0
      --cert /workspace/certs/cert.pem
      --key /workspace/certs/key.pem
      --oauth basic
    volumes:
      - ./azurite-certs/certs:/workspace/certs

Configure the devcontainer to trust the certificates

This step is more complicated. To enable trust, you need to install the minica root certificate in to the relevant trust stores inside the container. Thankfully, i found this script which does the trick. To use it, we’ll create our own Dockerfile which defines the devcontainer. We’ll base it on the existing image, and add a couple of steps

FROM mcr.microsoft.com/devcontainers/python:1-3.11-bullseye
# Switch to root user to install packages and update certificates
USER root
# Install ca-certificates package and libnss3-tools
RUN apt-get update && apt-get install -y ca-certificates libnss3-tools
# Copy the minica certificate to the container and install it
COPY ./azurite-certs/minica.pem /usr/local/share/ca-certificates/minica.crt
COPY ./azurite-certs/trust_minica.sh /usr/local/bin/trust_minica.sh
# Update CA certificates
RUN chmod +x /usr/local/bin/trust_minica.sh
RUN /usr/local/bin/trust_minica.sh
RUN update-ca-certificates
# Switch back to the non-root user (devcontainer default user)
USER vscode
# Keep the container running
CMD ["/bin/sh", "-c", "while sleep 1000; do :; done"]

And we’ll put the script (not my work – from this Gist) in the azurite-certs folder as trust_minica.sh:

#!/bin/sh
### Script installs minica.pem to certificate trust store of applications using NSS
### https://gist.github.com/mwidmann/115c2a7059dcce300b61f625d887e5dc
### (e.g. Firefox, Thunderbird, Chromium)
### Mozilla uses cert8, Chromium and Chrome use cert9
###
### Requirement: apt install libnss3-tools
###
###
### CA file to install (customize!)
### Retrieve Certname: openssl x509 -noout -subject -in minica.pem
###
certfile="minica.pem"
certname="minica root ca"
###
### For cert8 (legacy - DBM)
###
for certDB in $(find ~/ -name "cert8.db"); do
    certdir=$(dirname ${certDB})
    certutil -A -n "${certname}" -t "TCu,Cu,Tu" -i ${certfile} -d dbm:${certdir}
done
###
### For cert9 (SQL)
###
for certDB in $(find ~/ -name "cert9.db"); do
    certdir=$(dirname ${certDB})
    certutil -A -n "${certname}" -t "TCu,Cu,Tu" -i ${certfile} -d sql:${certdir}
done

Now we need to update our compose file to point to this new Dockerfile and use that instead of the base image. Our docker-compose.yml now looks like this:

services:
  devcontainer:
    build:
      context: .
      dockerfile: Dockerfile
    platform: linux/amd64
    volumes:
      - ..:/workspace:delegated
    ports:
      - "5000:5000"
    environment:
      - POETRY_VIRTUALENVS_IN_PROJECT=true
    command: /bin/sh -c "while sleep 1000; do :; done"
    network_mode: "host"
  azurite:
    image: mcr.microsoft.com/azure-storage/azurite
    ports:
      - "127.0.0.1:10000:10000"
      - "127.0.0.1:10001:10001"
      - "127.0.0.1:10002:10002"
    command: >
      azurite
      --blobHost 0.0.0.0
      --queueHost 0.0.0.0
      --tableHost 0.0.0.0
      --cert /workspace/certs/cert.pem
      --key /workspace/certs/key.pem
      --oauth basic
    volumes:
      - ./azurite-certs/certs:/workspace/certs

Remember to rebuild your devcontainer after making these changes

Configure the local credential for OAuth

You should now be able to run your code and you won’t receive any SSL certificate errors. But the credential provider will most likely complain that it could not find a credential:

[2024-08-30T10:23:59.038Z] DefaultAzureCredential failed to retrieve a token from the included credentials.
[2024-08-30T10:23:59.039Z] Attempted credentials:
[2024-08-30T10:23:59.039Z]      EnvironmentCredential: EnvironmentCredential authentication unavailable. Environment variables are not fully configured.
[2024-08-30T10:23:59.039Z] Visit https://aka.ms/azsdk/python/identity/environmentcredential/troubleshoot to troubleshoot this issue.
[2024-08-30T10:23:59.039Z]      ManagedIdentityCredential: ManagedIdentityCredential authentication unavailable. The requested identity has not been assigned to this resource. Error: Unexpected response "{'error': 'invalid_request', 'error_description': 'Identity not found'}"
[2024-08-30T10:23:59.039Z]      SharedTokenCacheCredential: SharedTokenCacheCredential authentication unavailable. No accounts were found in the cache.
[2024-08-30T10:23:59.039Z]      AzureCliCredential: Please run 'az login' to set up an account
[2024-08-30T10:23:59.039Z]      AzurePowerShellCredential: PowerShell is not installed
[2024-08-30T10:23:59.039Z]      AzureDeveloperCliCredential: Azure Developer CLI could not be found. Please visit https://aka.ms/azure-dev for installation instructions and then,once installed, authenticate to your Azure account using 'azd auth login'.
[2024-08-30T10:23:59.039Z] To mitigate this issue, please refer to the troubleshooting guidelines here at https://aka.ms/azsdk/python/identity/defaultazurecredential/troubleshoot.

The solution for this is relatively simple. Azurite only performs basic validation on the presented token – checking for expiry and structure, but does not validate the permissions associated with the token. So we can simply log in with the Azure CLI (az login) to ensure that a principal is available.

Configure Azure Storage Explorer to trust the minica root certificate

Azure Storage Explorer also needs to be configured to trust the new root CA. To do this, click Edit > SSL Certificates > Import Certificates and import the minica.pem file:

Next, reestablish your connection with Azurite and check the Use HTTPS box:

You can access this folder at ~/Library/Application Support/StorageExplorer/certs/ on a Mac. Restart Azurite and you’re good to go!