Metadata-Version: 2.4
Name: django-ecp-signer
Version: 0.1.2
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 |

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

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

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

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

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

### Keys response

```json
{
  "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

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