Metadata-Version: 2.4
Name: django-ecp-signer
Version: 0.1.4
Summary: Django authentication via ECDSA digital signatures and PKCS#12 certificates
License: MIT License
        
        Copyright (c) 2026
        
        Permission is hereby granted, free of charge, to any person obtaining a copy
        of this software and associated documentation files (the "Software"), to deal
        in the Software without restriction, including without limitation the rights
        to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
        copies of the Software, and to permit persons to whom the Software is
        furnished to do so, subject to the following conditions:
        
        The above copyright notice and this permission notice shall be included in all
        copies or substantial portions of the Software.
        
        THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
        IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
        FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
        AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
        LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
        OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
        SOFTWARE.
Project-URL: Homepage, https://github.com/zakhkatya/django-ecp-signer
Project-URL: Repository, https://github.com/zakhkatya/django-ecp-signer
Project-URL: Issues, https://github.com/zakhkatya/django-ecp-signer/issues
Classifier: Framework :: Django
Classifier: Framework :: Django :: 4.2
Classifier: Framework :: Django :: 5.0
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Internet :: WWW/HTTP
Classifier: Topic :: Security :: Cryptography
Requires-Python: >=3.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: django>=4.2
Requires-Dist: cryptography>=42.0
Dynamic: license-file

# 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.

```bash
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_key` → `private_key.pem` (encrypted, keep secret)
   - `GET /ecp/keys/?file=certificate` → `certificate.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**

```python
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**

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

**3. views.py**

```python
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**

```bash
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

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

### Keys response (JSON)

```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:

```bash
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

```bash
pip install pytest pytest-django
pytest tests/ -v
```
