Skip to main content

Django authentication via ECDSA digital signatures and PKCS#12 certificates

Project description

django-ecp-auth

Two-factor authentication for Django using ECDSA digital signatures. Plugs into any existing project via two mixins — no replacement of existing auth logic required.

pip install django-ecp-signer

What is this?

django-ecp-auth adds a digital signature layer on top of standard Django username/password authentication.

Without package With package
username + password username + password + private key

Upon registration, the server generates an ECDSA key pair and displays the private key as PEM text (like backup codes) — the user copies and saves it. On every login, JavaScript signs a one-time server challenge with that key, and the server verifies the signature against the stored public certificate.

The private key never leaves the user's machine after registration. The server stores only the public certificate.


Key concepts

Private key — shown once after registration as PEM text. Used to sign the challenge. Never sent to the server.

Public key — embedded in the X.509 certificate stored in the database. Used to verify the signature.

Nonce — a random string generated by the server for each login attempt. The client signs exactly this string. This prevents replay attacks: an intercepted signature is useless because it was made for a specific one-time value that is immediately marked as used.


Registration flow

  1. User submits the registration form — username and password.
  2. CreateView creates the User object as usual.
  3. ECPGenerateMixin runs automatically after form_valid():
    • Reads user = form.instance (the newly created user).
    • Generates an ECDSA P-256 key pair.
    • Builds a self-signed X.509 certificate valid for 365 days.
    • Saves only the public certificate to the database (ECPCertificate).
    • Stores the private key and certificate PEM text in the session.
  4. Frontend calls GET /ecp/keys/ — receives JSON with private_key and certificate PEM strings, then displays them for the user to copy. Session is cleared immediately after serving.

After registration the server retains no private key — it exists only on the user's side.


Login flow

  1. The login page fetches a nonce from GET /ecp/challenge/.
  2. User fills in username and password, and provides their saved private key.
  3. JavaScript signs the nonce with the private key (ECDSA SHA-256).
  4. The form submits username, password, nonce_id, and signature.
  5. The server runs two checks in sequence:
    • Password check — standard Django authenticate(username, password). Fails fast if credentials are wrong.
    • Signature check — looks up the user, fetches the certificate from the database (never from the client), verifies the ECDSA signature against the nonce, and checks that the certificate has not expired.
  6. On success: nonce is consumed (used=True), session is opened, user is redirected.

What is stored where

User's side — PEM text of the private key (copied during registration). Without it, login is impossible.

Database — hashed password, public certificate in ecp_auth_ecpcertificate, one-time nonces in ecp_auth_ecpnonce. No private key is ever written to the database.


Security

Threat Defense
Replay attack Nonce marked used=True after first use
Forged signature ECDSA verification fails without the real private key
Stolen certificate Server fetches cert from DB, not from client
Expired certificate is_expired() check on every login
Stale nonce Nonces expire after 5 minutes (configurable)
Password stolen, no private key Cannot produce a valid signature
Private key stolen, no password Password check rejects before signature is verified
Nonce spam Rate limit: 10 requests per minute per IP → 429

Setup

1. settings.py

INSTALLED_APPS = [
    ...
    'ecp_auth',
]

AUTHENTICATION_BACKENDS = [
    'ecp_auth.backends.ECPAuthenticationBackend',
]

# Optional: nonce lifetime (default 5 minutes)
from datetime import timedelta
NONCE_LIFETIME = timedelta(minutes=5)

2. urls.py

path("ecp/", include("ecp_auth.urls")),

3. views.py

from ecp_auth.mixins import ECPGenerateMixin, ECPLoginMixin

class RegisterView(ECPGenerateMixin, CreateView):
    ...

class LoginView(ECPLoginMixin, FormView):
    ...

ECPGenerateMixin reads the user from form.instance, so it must be used with CreateView or any view whose form has a .instance attribute set to the created user.

ECPLoginMixin expects the login form to have these fields in cleaned_data:

Field Description
username User's username
password User's password
signature DER-encoded ECDSA signature of the nonce (bytes)
nonce_id Primary key of the nonce returned by /ecp/challenge/

4. Migrate

python manage.py migrate

Endpoints

Method URL Description
GET /ecp/challenge/ Returns a one-time nonce. Rate limited to 10 req/min per IP.
GET /ecp/keys/ Returns private_key and certificate PEM text from session. One-time — clears session after serving.

Challenge response

{
  "nonce": "3f8a1c...",
  "nonce_id": 42
}

Sign nonce with the private key and submit nonce_id with the login form.

Keys response

{
  "private_key": "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----\n",
  "certificate": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n"
}

Display both to the user as backup codes to copy and store.


Exceptions

All exceptions inherit from ECPAuthError so you can catch them broadly or specifically.

ECPAuthError
├── InvalidSignatureError
├── CertificateExpiredError
├── InvalidCertificateError
├── NonceExpiredError
└── NonceNotFoundError

Requirements

  • Python >= 3.12
  • Django >= 4.2
  • cryptography >= 42.0

Running tests

pip install pytest pytest-django
pytest tests/ -v

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_ecp_signer-0.1.2.tar.gz (18.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_ecp_signer-0.1.2-py3-none-any.whl (19.5 kB view details)

Uploaded Python 3

File details

Details for the file django_ecp_signer-0.1.2.tar.gz.

File metadata

  • Download URL: django_ecp_signer-0.1.2.tar.gz
  • Upload date:
  • Size: 18.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for django_ecp_signer-0.1.2.tar.gz
Algorithm Hash digest
SHA256 e36f4dbb0c79128e1c64c07f70c37f7806dc48326936af455ade6643ba23fe00
MD5 99bf07491e99452f962f825ed7fd0a20
BLAKE2b-256 0e7d18096f9c09dd83778ba2298d2ae85e504bda13121e405568e631cefef997

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_ecp_signer-0.1.2.tar.gz:

Publisher: publish.yml on zakhkatya/django-ecp-signer

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_ecp_signer-0.1.2-py3-none-any.whl.

File metadata

File hashes

Hashes for django_ecp_signer-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 b94f8a9f060eeb7afe3b62d29f9bbead37071bc313512ab3b217d4d75b71e7cc
MD5 890749c17e95b2de2130998e174421ca
BLAKE2b-256 35d09a98119f928467ff412936696c947e7e9bbac82bfdff7e6c772f87e91848

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_ecp_signer-0.1.2-py3-none-any.whl:

Publisher: publish.yml on zakhkatya/django-ecp-signer

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page