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

Uploaded Python 3

File details

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

File metadata

  • Download URL: django_ecp_signer-0.1.4.tar.gz
  • Upload date:
  • Size: 20.9 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.4.tar.gz
Algorithm Hash digest
SHA256 5f45555f675c418ebca69bc048b7d8158b2de57b03d673b17358afc440b9bc89
MD5 816363eb84854135b39c699573123ae7
BLAKE2b-256 d2da6936dde3917ffe56f41d8beefc4f7d25c370c0586bac15984f4b1e54da39

See more details on using hashes here.

Provenance

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

File metadata

File hashes

Hashes for django_ecp_signer-0.1.4-py3-none-any.whl
Algorithm Hash digest
SHA256 519773856f50722a270e3f466d7580a4abaa7290d72ea477ef21bff6c2e82720
MD5 30d398899b94d8a928444ca051b223aa
BLAKE2b-256 42084a8e01565e7cc868688ab42935129c921503397add703eea72a99d109f81

See more details on using hashes here.

Provenance

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