Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 72 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,59 +1,82 @@
# secure-qrcode
# 🔐 Secure QR Code Generator

[![Build Status](https://github.com/allisson/secure-qrcode/actions/workflows/lint-and-tests.yml/badge.svg)](https://github.com/allisson/secure-qrcode/actions)
[![Docker Image Version](https://img.shields.io/docker/v/allisson/secure-qrcode)](https://hub.docker.com/r/allisson/secure-qrcode)

Encrypt your data using the modern ChaCha20-Poly1305 cipher and export it into a secure QR code.
> 🚀 **Encrypt your sensitive data using modern cryptography and turn it into secure QR codes!**

Transform your private information into unreadable encrypted data using the powerful **ChaCha20-Poly1305** cipher, then encode it as a QR code for easy sharing and storage. 🔒📱

## ✨ Features

- 🔐 **Military-grade encryption** with ChaCha20-Poly1305
- 🏗️ **PBKDF2 key derivation** for enhanced security
- 📱 **QR code generation** for easy data transfer
- 🌐 **RESTful API** with interactive documentation
- 🐳 **Docker support** for easy deployment
- ⚡ **FastAPI backend** for high performance

## 📋 Version Compatibility

## about the versions
⚠️ **Important**: Version "2.x" is **not compatible** with version "1.x".

The current version "2.x" is incompatible with version "1.x".
For legacy "1.x" QR codes, check the [v1.6.0 documentation](https://github.com/allisson/secure-qrcode/tree/v1.6.0). 📚

To encrypt and decrypt QR codes using version "1.x" use this documentation: https://github.com/allisson/secure-qrcode/tree/v1.6.0
## 🔍 How It Works

## how it works
1. 📝 **Input**: Your secret message and encryption key
2. 🔑 **Derivation**: PBKDF2 transforms your key into a 32-byte cryptographic key
3. 🔒 **Encryption**: ChaCha20-Poly1305 encrypts your data with authenticated encryption
4. 📱 **QR Generation**: Encrypted data becomes a scannable QR code

The system receives your key and uses a key derivation function with PBKDF2 to obtain a 32-byte derived key to be applied to the ChaCha20-Poly1305 algorithm.
```
Plaintext + Key → PBKDF2 → ChaCha20-Poly1305 → QR Code
```

## access via browser
## 🚀 Quick Start

Open the url https://secure-qrcode.onrender.com on your browser (This is a free instance type and will stop upon inactivity, so be patient).
### 🌐 Try It Online

If you want to run this on your local machine, see the next section.
Visit the [live demo](https://secure-qrcode.onrender.com) in your browser! 🌍

## run the api
> 💡 **Note**: This is a free instance that may sleep during inactivity. Please be patient! ⏳

The server can be started using a docker image:
### 🐳 Run Locally with Docker

```bash
# Pull and run the API server
docker run --rm -p 8000:8000 allisson/secure-qrcode

# Access the web interface
open http://localhost:8000
```

Now the API server will be running on port 8000 and you can open the url http://localhost:8000 on your browser.
That's it! Your secure QR code generator is now running locally. 🎉

## api documentation
## 📖 API Documentation

You can access the API documentation using these two endpoints:
- http://localhost:8000/docs
- http://localhost:8000/redoc
Explore the interactive API docs:
- 📋 **Swagger UI**: http://localhost:8000/docs
- 📄 **ReDoc**: http://localhost:8000/redoc

## generate a secure QR code
## 💻 Usage Examples

Call the API passing at least the plaintext and key fields:
### 🔐 Generate a Secure QR Code

```bash
curl --location 'http://localhost:8000/v1/encode' \
--header 'Content-Type: application/json' \
--data '{
"plaintext": "my super secret text",
"key": "my super secret key"
}' | jq -r '.content' | base64 --decode > qrcode.png
```
}' | jq -r '.content' | base64 --decode > secure_qrcode.png

Now you can open the qrcode.png file and do whatever you want.
# Your encrypted QR code is saved as secure_qrcode.png! 🖼️
```

## decrypt the QR code
### 🔓 Decrypt a QR Code

Use any program that read a QR code, the content will be something like this:
First, scan your QR code to get the encrypted data (it looks like this):

```json
{
Expand All @@ -65,7 +88,7 @@ Use any program that read a QR code, the content will be something like this:
}
```

Now call the API passing the encrypted_data and the key:
Then decrypt it:

```bash
curl --location 'http://localhost:8000/v1/decode' \
Expand All @@ -82,16 +105,36 @@ curl --location 'http://localhost:8000/v1/decode' \
}' | jq
```

**Response:**
```json
{
"decrypted_data": "my super secret text"
}
```

## change the value of PBKDF2 iterations
## ⚙️ Configuration

The default value for PBKDF2 iterations is 1200000, you can change this value using the "secure_qrcode_pbkdf2_iterations" environment variable.
### 🔧 Customize PBKDF2 Iterations

The default PBKDF2 iterations (1,200,000) provide excellent security. For custom security levels:

```bash
# Run with custom iterations (example: 1,000,000)
docker run --rm -p 8000:8000 \
-e secure_qrcode_pbkdf2_iterations=1000000 \
allisson/secure-qrcode
```
docker run --rm -p 8000:8000 -e secure_qrcode_pbkdf2_iterations=1000000 allisson/secure-qrcode
```

> 💡 **Tip**: Higher iterations = better security but slower performance. Find your balance! ⚖️

## 🤝 Contributing

We welcome contributions! Please feel free to submit issues, feature requests, or pull requests. 🛠️

## 📄 License

This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 📋

---

**Made with ❤️ for secure data sharing**
7 changes: 6 additions & 1 deletion secure_qrcode/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
from secure_qrcode.qrcode import make

app = FastAPI(
title="Secure QR code",
title="Secure QR Code",
description="Encrypt your data using the modern ChaCha20-Poly1305 cipher and export it into a secure QR code",
)
app.mount("/static", StaticFiles(directory="static"), name="static")
Expand All @@ -28,6 +28,7 @@

@app.exception_handler(DecryptError)
def decrypt_error_exception_handler(request: Request, exc: DecryptError):
"""Handle DecryptError exceptions by returning a JSON error response."""
return JSONResponse(
status_code=400,
content={"message": "Incorrect decryption, please check your data"},
Expand All @@ -36,11 +37,13 @@ def decrypt_error_exception_handler(request: Request, exc: DecryptError):

@app.get("/", response_class=HTMLResponse, tags=["home"])
def index(request: Request):
"""Render the home page template."""
return templates.TemplateResponse(request, "index.html")


@app.post("/v1/encode", status_code=201, tags=["api"])
def encode(request: EncodeRequest) -> EncodeResponse:
"""Encrypt plaintext data and generate a QR code image."""
encrypted_data = encrypt(request.plaintext, request.key)
img_io = make(
encrypted_data,
Expand All @@ -58,10 +61,12 @@ def encode(request: EncodeRequest) -> EncodeResponse:
tags=["api"],
)
def decode(request: DecodeRequest) -> DecodeResponse:
"""Decrypt encrypted data from a QR code."""
decrypted_data = decrypt(request.encrypted_data, request.key)
return DecodeResponse(decrypted_data=decrypted_data)


@app.get("/healthz", tags=["healthcheck"])
def healthz() -> HealthResponse:
"""Health check endpoint to verify service availability."""
return HealthResponse(success=True)
2 changes: 2 additions & 0 deletions secure_qrcode/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@


class Settings(BaseSettings):
"""Application configuration settings."""

model_config = SettingsConfigDict(env_prefix="secure_qrcode_")
pbkdf2_iterations: int = Field(description="PBKDF2 iterations", default=1_200_000)

Expand Down
7 changes: 5 additions & 2 deletions secure_qrcode/crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@


def derive_key(key: str, salt: bytes, iterations: int) -> bytes:
"""Derive a cryptographic key using PBKDF2."""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
Expand All @@ -22,6 +23,7 @@ def derive_key(key: str, salt: bytes, iterations: int) -> bytes:


def encrypt(plaintext: str, key: str) -> EncryptedData:
"""Encrypt plaintext using ChaCha20-Poly1305 with PBKDF2 key derivation."""
salt = os.urandom(16)
iterations = settings.pbkdf2_iterations
associated_data = os.urandom(16)
Expand All @@ -39,6 +41,7 @@ def encrypt(plaintext: str, key: str) -> EncryptedData:


def decrypt(encrypted_data: EncryptedData, key: str) -> str:
"""Decrypt encrypted data using ChaCha20-Poly1305."""
salt = b64decode(encrypted_data.salt)
associated_data = b64decode(encrypted_data.associated_data)
nonce = b64decode(encrypted_data.nonce)
Expand All @@ -48,8 +51,8 @@ def decrypt(encrypted_data: EncryptedData, key: str) -> str:
try:
plaintext = chacha.decrypt(nonce, ciphertext, associated_data)
except InvalidTag as exc:
raise DecryptError("Incorrect decryption, exc=Invalid Tag") from exc
raise DecryptError("Incorrect decryption, invalid key or corrupted data") from exc
except Exception as exc:
raise DecryptError(f"Incorrect decryption, exc={exc}") from exc
raise DecryptError("Decryption failed due to unexpected error") from exc

return plaintext.decode()
2 changes: 2 additions & 0 deletions secure_qrcode/exceptions.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,4 @@
class DecryptError(Exception):
"""Exception raised when decryption fails."""

pass
16 changes: 16 additions & 0 deletions secure_qrcode/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@


class EncryptedData(BaseModel):
"""Model representing encrypted data with all necessary components."""

salt: str
iterations: int
associated_data: str
Expand All @@ -12,13 +14,17 @@ class EncryptedData(BaseModel):


class ErrorCorrection(IntEnum):
"""Enumeration for QR code error correction levels."""

Level_L = 1
Level_M = 0
Level_Q = 3
Level_H = 2


class EncodeRequest(BaseModel):
"""Request model for encoding plaintext to QR code."""

plaintext: str = Field(min_length=1, max_length=2048, description="Text to be encrypted")
key: str = Field(min_length=1, max_length=32, description="Key used to encrypt the data")
error_correction: ErrorCorrection = Field(
Expand All @@ -30,22 +36,32 @@ class EncodeRequest(BaseModel):


class EncodeResponse(BaseModel):
"""Response model for encode operation containing QR code image data."""

content: str = Field(description="Image content encoded in base64")
media_type: str = Field(description="The media type of the image")


class DecodeRequest(BaseModel):
"""Request model for decoding QR code to plaintext."""

encrypted_data: EncryptedData = Field(description="The encrypted data read from the image")
key: str = Field(min_length=1, max_length=32, description="Key used to encrypt the data")


class DecodeResponse(BaseModel):
"""Response model for decode operation containing decrypted plaintext."""

decrypted_data: str = Field(description="The result decrypted data")


class DecryptErrorResponse(BaseModel):
"""Response model for decryption error."""

message: str


class HealthResponse(BaseModel):
"""Response model for health check endpoint."""

success: bool
1 change: 1 addition & 0 deletions secure_qrcode/qrcode.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ def make(
box_size: int = 10,
border: int = 4,
) -> BytesIO:
"""Generate a QR code image from encrypted data."""
data = encrypted_data.model_dump_json()
qr = qrcode.QRCode(
version=None,
Expand Down
3 changes: 3 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@

@pytest.fixture
def key():
"""Fixture providing a test encryption key."""
return "my super secret key"


@pytest.fixture
def plaintext():
"""Fixture providing test plaintext data."""
return "super secret text"


Expand All @@ -29,4 +31,5 @@ def sample_encrypted_data():

@pytest.fixture
def client():
"""Fixture providing a FastAPI test client."""
return TestClient(app)
5 changes: 5 additions & 0 deletions tests/test_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@


def test_index(client):
"""Test the home page endpoint."""
response = client.get("/")

assert response.status_code == 200


def test_encode(client, plaintext, key):
"""Test encoding plaintext to QR code."""
request = EncodeRequest(plaintext=plaintext, key=key)
response = client.post("/v1/encode", json=request.model_dump())

Expand All @@ -20,6 +22,7 @@ def test_encode(client, plaintext, key):


def test_decode(client, plaintext, key, sample_encrypted_data):
"""Test decoding QR code to plaintext."""
request = DecodeRequest(encrypted_data=sample_encrypted_data, key=key)
response = client.post("/v1/decode", json=request.model_dump())

Expand All @@ -29,6 +32,7 @@ def test_decode(client, plaintext, key, sample_encrypted_data):


def test_decode_error(client, key, sample_encrypted_data):
"""Test decoding with invalid data returns error."""
encrypted_data = sample_encrypted_data.model_copy(
update={"associated_data": b64encode(b"invalid-aad").decode()}
)
Expand All @@ -41,6 +45,7 @@ def test_decode_error(client, key, sample_encrypted_data):


def test_healthz(client):
"""Test the health check endpoint."""
response = client.get("/healthz")

assert response.status_code == 200
4 changes: 3 additions & 1 deletion tests/test_crypto.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@


def test_encrypt_decrypt(plaintext, key):
"""Test that encryption and decryption work correctly."""
encrypted_data = encrypt(plaintext, key)

assert encrypted_data.salt
Expand All @@ -18,11 +19,12 @@ def test_encrypt_decrypt(plaintext, key):


def test_decrypt_error(key, sample_encrypted_data):
"""Test that decryption fails with invalid data."""
encrypted_data = sample_encrypted_data.model_copy(
update={"associated_data": b64encode(b"invalid-aad").decode()}
)

with pytest.raises(DecryptError) as excinfo:
decrypt(encrypted_data, key)

assert str(excinfo.value) == "Incorrect decryption, exc=Invalid Tag"
assert str(excinfo.value) == "Incorrect decryption, invalid key or corrupted data"
Loading