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 file

Upon registration, the server generates an ECDSA key pair. The user downloads the private key as an encrypted .pem file (protected by a passphrase they choose) and the public certificate as a separate .pem file. On every login, JavaScript reads the private key file, decrypts it with the passphrase, signs a one-time server challenge, and submits the signature. The server verifies the signature against the stored public certificate.

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


Key concepts

private_key.pem — downloaded once after registration. Encrypted with a passphrase (AES-256-CBC). Used client-side to sign the challenge. Never sent to the server.

certificate.pem — the public X.509 certificate. Stored in the database server-side. Also given to the user so they can re-upload it later (e.g. to a new server instance) without generating a new key pair.

Nonce — a random string generated by the server for each login attempt. Signed by the client, consumed atomically on the server (SELECT FOR UPDATE) after first use to prevent replay attacks.


Registration flow

  1. User submits the registration form — username, password, and key_password (passphrase for the private key).
  2. CreateView creates the User object as usual.
  3. ECPGenerateMixin runs automatically:
    • 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).
    • Encrypts the private key with key_password and stores both PEM strings in the session.
  4. Frontend offers two separate downloads:
    • GET /ecp/keys/?file=private_keyprivate_key.pem (encrypted, keep secret)
    • GET /ecp/keys/?file=certificatecertificate.pem (public, safe to share)

Each file is cleared from the session individually after it is served.


Login flow

  1. The login page fetches a nonce: GET /ecp/challenge/.
  2. User fills in username and password, selects their private_key.pem file, and enters their key_password.
  3. JavaScript reads the file, decrypts the private key locally, signs the nonce (ECDSA SHA-256), then discards the key from memory. The key file and passphrase are never sent to the server.
  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).
    • Signature check — fetches the certificate from the database, atomically consumes the nonce, and verifies the ECDSA signature.
  6. On success: session is opened, user is redirected.

Re-registering a certificate (BYOK)

A user can upload their saved certificate.pem to replace the one stored on the server — useful when moving to a new server instance, after a database reset, or when bringing a self-generated key pair:

POST /ecp/certificate/
Content-Type: multipart/form-data

certificate=<certificate.pem>

The server validates that the certificate is not expired and that its Common Name (CN) matches the username, then stores it. The user's existing private_key.pem continues to work for signing.


What is stored where

Location What
User's machine private_key.pem (encrypted). Without it, login is impossible.
User's machine certificate.pem (public). Needed only for re-registration.
Database Hashed password, public certificate (ECPCertificate), one-time nonces (ECPNonce). No private key ever.

Security

Threat Defense
Replay attack Nonce consumed atomically (SELECT FOR UPDATE) after first use
Concurrent replay Transaction lock prevents two requests from using the same nonce
Forged signature ECDSA verification fails without the real private key
Stolen certificate Server fetches cert from DB, not from client — cert alone is useless
Expired / not-yet-valid cert Full validity window checked on every login
Stale nonce Nonces expire after 5 minutes (configurable)
Stolen key file Encrypted with AES-256-CBC — useless without the passphrase
Password stolen, no key file Cannot produce a valid signature
Key file stolen, no passphrase Cannot decrypt the private key
Nonce spam Rate limit: 10 requests per minute per IP → 429
Proxy IP bypass Rate limiter reads X-Forwarded-For when present

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 and must be used with CreateView. The registration form must include a key_password field — this passphrase encrypts the private key.

ECPLoginMixin expects the login form to have:

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

4. Migrate

python manage.py migrate

Endpoints

Method URL Auth Description
GET /ecp/challenge/ No Returns a one-time nonce. Rate limited to 10 req/min per IP.
GET /ecp/keys/ Yes Returns private_key and certificate as JSON. One-time.
GET /ecp/keys/?file=private_key Yes Downloads private_key.pem. Clears only the key from session.
GET /ecp/keys/?file=certificate Yes Downloads certificate.pem. Clears only the cert from session.
POST /ecp/certificate/ Yes Upload a custom certificate.pem to replace the stored one.

Challenge response

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

Keys response (JSON)

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

File download

Returns application/x-pem-file with Content-Disposition: attachment. Each file is cleared from the session only when it is served.

Certificate upload

Returns {"status": "ok"} on success, or {"error": "..."} with status 400 on failure.


Maintenance

Remove stale nonces periodically to prevent table bloat:

python manage.py cleanup_nonces

Deletes all nonces that are used or older than NONCE_LIFETIME. Safe to run via cron or Celery beat.


Exceptions

All exceptions inherit from ECPAuthError.

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.7.tar.gz (21.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.7-py3-none-any.whl (22.3 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: django_ecp_signer-0.1.7.tar.gz
  • Upload date:
  • Size: 21.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.7.tar.gz
Algorithm Hash digest
SHA256 174ccf5fac0357888cbc917ffcf3c941ec366a1382704415ecbb40b9cf52b848
MD5 b2a25858c3b7274d0a9446fe5c7b3e0b
BLAKE2b-256 25ea9ac6da5ca3852669a4e79aa6ec0888206a2e2f86303b0b47dedaac594c77

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_ecp_signer-0.1.7.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.7-py3-none-any.whl.

File metadata

File hashes

Hashes for django_ecp_signer-0.1.7-py3-none-any.whl
Algorithm Hash digest
SHA256 655e744792791d21e89649ea1e4a46b7af2344ccebc6884d54540b6c458afbba
MD5 198cfef2283d3f009681cddfe62d8e4f
BLAKE2b-256 d01836ad9c99400964f61e02e0bac2994658dd0ca9523550dbd6abb84b4fc360

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_ecp_signer-0.1.7-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