Initial commit: SecPaste encrypted pastebin client
SecPaste is a Python library and CLI tool for sharing encrypted content via public pastebin services with zero-knowledge architecture. Features: - Pluggable crypto backends (AES-256-GCM, ChaCha20-Poly1305, Kyber-768) - Pluggable pastebin providers (dpaste.com, extensible) - URL fragment key storage (key never sent to server) - Both CLI and library usage - Post-quantum cryptography support (experimental) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
227
README.md
Normal file
227
README.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# SecPaste
|
||||||
|
|
||||||
|
**Encrypted pastebin client with pluggable cryptography backends**
|
||||||
|
|
||||||
|
SecPaste is a Python library and CLI tool for sharing encrypted content via public pastebin services. All encryption happens client-side, and the encryption key stays in the URL fragment (after `#`) so it never reaches the server.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Zero-knowledge architecture**: Server only stores encrypted data
|
||||||
|
- **Pluggable crypto backends**: AES-256-GCM, ChaCha20-Poly1305, and experimental post-quantum (Kyber)
|
||||||
|
- **Pluggable pastebin providers**: Easy to add support for any pastebin service
|
||||||
|
- **Both CLI and library**: Use as a command-line tool or import in your code
|
||||||
|
- **URL fragment key storage**: Encryption key never sent to server (stays after `#`)
|
||||||
|
|
||||||
|
## Security Model
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||||
|
│ Client │ │ Pastebin │ │ Reader │
|
||||||
|
│ │ │ Server │ │ │
|
||||||
|
│ plaintext │ │ │ │ │
|
||||||
|
│ ↓ │ │ │ │ │
|
||||||
|
│ encrypt │ │ │ │ │
|
||||||
|
│ ↓ │────────▶│ [encrypted] │────────▶│ fetch │
|
||||||
|
│ ciphertext │ POST │ content │ GET │ ↓ │
|
||||||
|
│ │ │ │ │ decrypt │
|
||||||
|
│ key ───────────────────────────────────────────────▶ ↓ │
|
||||||
|
│ (URL #fragment, never sent to server) │ plaintext │
|
||||||
|
└─────────────┘ └──────────────┘ └─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic installation
|
||||||
|
pip install cryptography requests
|
||||||
|
|
||||||
|
# For post-quantum crypto support (optional)
|
||||||
|
pip install liboqs-python
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### CLI Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Paste from stdin (default: AES-256-GCM)
|
||||||
|
echo "secret message" | python -m secpaste.cli paste
|
||||||
|
|
||||||
|
# Paste a file
|
||||||
|
python -m secpaste.cli paste -f secret.txt
|
||||||
|
|
||||||
|
# Paste with ChaCha20-Poly1305
|
||||||
|
python -m secpaste.cli paste -f data.json -c chacha20-poly1305
|
||||||
|
|
||||||
|
# Paste with custom expiry and syntax highlighting
|
||||||
|
python -m secpaste.cli paste -f script.py --expiry week --syntax python
|
||||||
|
|
||||||
|
# Fetch and decrypt
|
||||||
|
python -m secpaste.cli fetch "https://dpaste.com/ABC123#aes-256-gcm:key..."
|
||||||
|
|
||||||
|
# Save fetched content to file
|
||||||
|
python -m secpaste.cli fetch "https://dpaste.com/ABC123#key..." -o output.txt
|
||||||
|
|
||||||
|
# List available options
|
||||||
|
python -m secpaste.cli list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Library Usage
|
||||||
|
|
||||||
|
```python
|
||||||
|
from secpaste.client import SecPasteClient
|
||||||
|
|
||||||
|
# Initialize client (default: AES-256-GCM + dpaste)
|
||||||
|
client = SecPasteClient()
|
||||||
|
|
||||||
|
# Paste content
|
||||||
|
url = client.paste("secret data", expiry="week")
|
||||||
|
print(f"Share this URL: {url}")
|
||||||
|
# Output: https://dpaste.com/ABC123#aes-256-gcm:key...
|
||||||
|
|
||||||
|
# Fetch and decrypt
|
||||||
|
content = client.fetch(url)
|
||||||
|
print(content) # "secret data"
|
||||||
|
|
||||||
|
# Use different cipher
|
||||||
|
client = SecPasteClient(cipher='chacha20-poly1305')
|
||||||
|
url = client.paste("encrypted with ChaCha20")
|
||||||
|
|
||||||
|
# Register custom cipher or provider
|
||||||
|
from secpaste.crypto.base import CipherBackend
|
||||||
|
class MyCipher(CipherBackend):
|
||||||
|
# ... implementation ...
|
||||||
|
pass
|
||||||
|
|
||||||
|
SecPasteClient.register_cipher('my-cipher', MyCipher)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Ciphers
|
||||||
|
|
||||||
|
| Cipher | Description | Key Size | Quantum-Safe? |
|
||||||
|
|--------|-------------|----------|---------------|
|
||||||
|
| `aes-256-gcm` | AES-256-GCM (default) | 32 bytes (~43 chars) | Symmetric: Yes |
|
||||||
|
| `chacha20-poly1305` | ChaCha20-Poly1305 | 32 bytes (~43 chars) | Symmetric: Yes |
|
||||||
|
| `kyber768-aes256-gcm` | Kyber-768 + AES-256 (experimental) | ~1KB (~1400 chars) | Yes (PQC) |
|
||||||
|
|
||||||
|
**Note**: Symmetric encryption (AES, ChaCha20) with 256-bit keys is already considered quantum-resistant. Post-quantum crypto (Kyber) protects against quantum attacks on key exchange, but produces much larger keys.
|
||||||
|
|
||||||
|
## Post-Quantum Cryptography
|
||||||
|
|
||||||
|
The `kyber768-aes256-gcm` cipher uses NIST-standardized Kyber-768 for key encapsulation combined with AES-256-GCM for data encryption.
|
||||||
|
|
||||||
|
**Pros:**
|
||||||
|
- Future-proof against quantum computer attacks on key exchange
|
||||||
|
- NIST-standardized algorithm
|
||||||
|
- Educational/experimental
|
||||||
|
|
||||||
|
**Cons:**
|
||||||
|
- Much larger keys (~1KB vs 32 bytes)
|
||||||
|
- Browser URL length limits may apply (typically 2KB-8KB)
|
||||||
|
- Requires `liboqs-python` library
|
||||||
|
- Less mature ecosystem
|
||||||
|
|
||||||
|
**Usage:**
|
||||||
|
```python
|
||||||
|
# Requires: pip install liboqs-python
|
||||||
|
from secpaste.crypto.pqc import KyberAESCipher
|
||||||
|
|
||||||
|
client = SecPasteClient(cipher='kyber768-aes256-gcm')
|
||||||
|
url = client.paste("quantum-safe secret")
|
||||||
|
# Warning: URL will be ~1.5KB longer than standard ciphers
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported Pastebin Providers
|
||||||
|
|
||||||
|
| Provider | Base URL | Notes |
|
||||||
|
|----------|----------|-------|
|
||||||
|
| `dpaste` | https://dpaste.com | Default, supports custom expiry |
|
||||||
|
|
||||||
|
### Adding Custom Providers
|
||||||
|
|
||||||
|
```python
|
||||||
|
from secpaste.providers.base import PastebinProvider
|
||||||
|
import requests
|
||||||
|
|
||||||
|
class MyPastebinProvider(PastebinProvider):
|
||||||
|
def paste(self, content: bytes, **kwargs) -> str:
|
||||||
|
# Upload content, return URL
|
||||||
|
pass
|
||||||
|
|
||||||
|
def fetch(self, paste_id: str) -> bytes:
|
||||||
|
# Fetch content, return bytes
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_name(self) -> str:
|
||||||
|
return "mypaste"
|
||||||
|
|
||||||
|
def get_base_url(self) -> str:
|
||||||
|
return "https://mypaste.com"
|
||||||
|
|
||||||
|
# Register it
|
||||||
|
SecPasteClient.register_provider('mypaste', MyPastebinProvider)
|
||||||
|
|
||||||
|
# Use it
|
||||||
|
client = SecPasteClient(provider='mypaste')
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
secpaste/
|
||||||
|
├── crypto/
|
||||||
|
│ ├── base.py # Abstract cipher interface
|
||||||
|
│ ├── aes.py # AES-256-GCM implementation
|
||||||
|
│ ├── chacha.py # ChaCha20-Poly1305 implementation
|
||||||
|
│ └── pqc.py # Kyber + AES (post-quantum)
|
||||||
|
├── providers/
|
||||||
|
│ ├── base.py # Abstract provider interface
|
||||||
|
│ └── dpaste.py # dpaste.com implementation
|
||||||
|
├── client.py # Main SecPaste client API
|
||||||
|
└── cli.py # Command-line interface
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- **Server trust**: The pastebin server cannot read your content (it's encrypted), but it can:
|
||||||
|
- See paste metadata (size, IP, timing)
|
||||||
|
- Deny service or delete pastes
|
||||||
|
- Serve malicious JavaScript (if viewing in browser)
|
||||||
|
|
||||||
|
- **URL security**: The encryption key is in the URL fragment (`#key`):
|
||||||
|
- Never logged in server access logs
|
||||||
|
- Not sent in HTTP Referer headers
|
||||||
|
- But visible in browser history and can be leaked via client-side tracking
|
||||||
|
|
||||||
|
- **Key size**: Larger keys (like Kyber) may hit browser URL length limits
|
||||||
|
|
||||||
|
- **No authentication**: Anyone with the URL can decrypt the content
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
- **cryptography**: For AES-256-GCM and ChaCha20-Poly1305
|
||||||
|
- **requests**: For HTTP API calls
|
||||||
|
- **liboqs-python** (optional): For post-quantum Kyber support
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License - feel free to use, modify, and distribute.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
PRs welcome! Areas for contribution:
|
||||||
|
- Additional pastebin providers (pastebin.com, termbin, privatebin, etc.)
|
||||||
|
- Additional cipher backends
|
||||||
|
- Better error handling
|
||||||
|
- Tests
|
||||||
|
- Browser extension
|
||||||
|
|
||||||
|
## Similar Projects
|
||||||
|
|
||||||
|
- **PrivateBin**: Full web application with similar security model
|
||||||
|
- **Magic Wormhole**: Secure file transfer with PAKE
|
||||||
|
- **Age**: Modern file encryption tool
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Reminder**: Never paste truly sensitive data (passwords, private keys) to public pastebins, even encrypted. Use proper secret management tools for production credentials.
|
||||||
3
__init__.py
Normal file
3
__init__.py
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
"""SecPaste: Encrypted pastebin client with pluggable crypto backends."""
|
||||||
|
|
||||||
|
__version__ = '0.1.0'
|
||||||
168
cli.py
Normal file
168
cli.py
Normal file
@@ -0,0 +1,168 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Command-line interface for SecPaste."""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from .client import SecPasteClient
|
||||||
|
|
||||||
|
|
||||||
|
def paste_command(args):
|
||||||
|
"""Handle paste command."""
|
||||||
|
# Read content
|
||||||
|
if args.file:
|
||||||
|
content = Path(args.file).read_text()
|
||||||
|
elif args.content:
|
||||||
|
content = args.content
|
||||||
|
else:
|
||||||
|
# Read from stdin
|
||||||
|
content = sys.stdin.read()
|
||||||
|
|
||||||
|
# Initialize client
|
||||||
|
client = SecPasteClient(cipher=args.cipher, provider=args.provider)
|
||||||
|
|
||||||
|
# Build provider options
|
||||||
|
kwargs = {}
|
||||||
|
if args.expiry:
|
||||||
|
kwargs['expiry'] = args.expiry
|
||||||
|
if args.syntax:
|
||||||
|
kwargs['syntax'] = args.syntax
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Paste
|
||||||
|
url = client.paste(content, **kwargs)
|
||||||
|
print(url)
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_command(args):
|
||||||
|
"""Handle fetch command."""
|
||||||
|
# Initialize client
|
||||||
|
client = SecPasteClient(provider=args.provider)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Fetch and decrypt
|
||||||
|
content = client.fetch(args.url)
|
||||||
|
|
||||||
|
# Output
|
||||||
|
if args.output:
|
||||||
|
Path(args.output).write_text(content)
|
||||||
|
print(f"Saved to: {args.output}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
print(content)
|
||||||
|
|
||||||
|
return 0
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
def list_command(args):
|
||||||
|
"""Handle list command."""
|
||||||
|
print("Available ciphers:")
|
||||||
|
for cipher in SecPasteClient.list_ciphers():
|
||||||
|
print(f" - {cipher}")
|
||||||
|
|
||||||
|
print("\nAvailable providers:")
|
||||||
|
for provider in SecPasteClient.list_providers():
|
||||||
|
print(f" - {provider}")
|
||||||
|
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main CLI entry point."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='SecPaste: Encrypted pastebin client with pluggable crypto',
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
# Paste from stdin with AES-256-GCM (default)
|
||||||
|
echo "secret data" | secpaste paste
|
||||||
|
|
||||||
|
# Paste file with ChaCha20
|
||||||
|
secpaste paste -f secret.txt -c chacha20-poly1305
|
||||||
|
|
||||||
|
# Paste with custom expiry
|
||||||
|
secpaste paste -f data.json --expiry week --syntax json
|
||||||
|
|
||||||
|
# Fetch and decrypt
|
||||||
|
secpaste fetch https://dpaste.com/ABC123#aes-256-gcm:key...
|
||||||
|
|
||||||
|
# List available options
|
||||||
|
secpaste list
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(dest='command', help='Command to execute')
|
||||||
|
|
||||||
|
# Paste command
|
||||||
|
paste_parser = subparsers.add_parser('paste', help='Encrypt and paste content')
|
||||||
|
paste_parser.add_argument(
|
||||||
|
'-f', '--file',
|
||||||
|
help='File to paste (default: read from stdin)'
|
||||||
|
)
|
||||||
|
paste_parser.add_argument(
|
||||||
|
'-t', '--content',
|
||||||
|
help='Text content to paste'
|
||||||
|
)
|
||||||
|
paste_parser.add_argument(
|
||||||
|
'-c', '--cipher',
|
||||||
|
default='aes-256-gcm',
|
||||||
|
help='Cipher backend to use (default: aes-256-gcm)'
|
||||||
|
)
|
||||||
|
paste_parser.add_argument(
|
||||||
|
'-p', '--provider',
|
||||||
|
default='dpaste',
|
||||||
|
help='Pastebin provider (default: dpaste)'
|
||||||
|
)
|
||||||
|
paste_parser.add_argument(
|
||||||
|
'--expiry',
|
||||||
|
choices=['onetime', 'hour', 'day', 'week', 'month', 'never'],
|
||||||
|
default='week',
|
||||||
|
help='Paste expiration (default: week)'
|
||||||
|
)
|
||||||
|
paste_parser.add_argument(
|
||||||
|
'--syntax',
|
||||||
|
help='Syntax highlighting language (e.g., python, json, text)'
|
||||||
|
)
|
||||||
|
paste_parser.set_defaults(func=paste_command)
|
||||||
|
|
||||||
|
# Fetch command
|
||||||
|
fetch_parser = subparsers.add_parser('fetch', help='Fetch and decrypt paste')
|
||||||
|
fetch_parser.add_argument(
|
||||||
|
'url',
|
||||||
|
help='Full URL with encryption key in fragment (#key)'
|
||||||
|
)
|
||||||
|
fetch_parser.add_argument(
|
||||||
|
'-o', '--output',
|
||||||
|
help='Output file (default: print to stdout)'
|
||||||
|
)
|
||||||
|
fetch_parser.add_argument(
|
||||||
|
'-p', '--provider',
|
||||||
|
default='dpaste',
|
||||||
|
help='Pastebin provider (default: dpaste)'
|
||||||
|
)
|
||||||
|
fetch_parser.set_defaults(func=fetch_command)
|
||||||
|
|
||||||
|
# List command
|
||||||
|
list_parser = subparsers.add_parser('list', help='List available ciphers and providers')
|
||||||
|
list_parser.set_defaults(func=list_command)
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.command:
|
||||||
|
parser.print_help()
|
||||||
|
return 1
|
||||||
|
|
||||||
|
# Execute command
|
||||||
|
return args.func(args)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
sys.exit(main())
|
||||||
175
client.py
Normal file
175
client.py
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
"""Main SecPaste client API."""
|
||||||
|
|
||||||
|
from typing import Optional, Dict
|
||||||
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
|
from .crypto.base import CipherBackend
|
||||||
|
from .crypto.aes import AESGCMCipher
|
||||||
|
from .crypto.chacha import ChaCha20Cipher
|
||||||
|
from .providers.base import PastebinProvider
|
||||||
|
from .providers.dpaste import DPasteProvider
|
||||||
|
|
||||||
|
|
||||||
|
class SecPasteClient:
|
||||||
|
"""Main client for encrypted pastebin operations."""
|
||||||
|
|
||||||
|
# Registry of available cipher backends
|
||||||
|
CIPHERS: Dict[str, type] = {
|
||||||
|
'aes-256-gcm': AESGCMCipher,
|
||||||
|
'chacha20-poly1305': ChaCha20Cipher,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Registry of available providers
|
||||||
|
PROVIDERS: Dict[str, type] = {
|
||||||
|
'dpaste': DPasteProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
cipher: str = 'aes-256-gcm',
|
||||||
|
provider: str = 'dpaste',
|
||||||
|
cipher_backend: Optional[CipherBackend] = None,
|
||||||
|
provider_backend: Optional[PastebinProvider] = None
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize SecPaste client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cipher: Name of cipher backend to use (default: aes-256-gcm)
|
||||||
|
provider: Name of pastebin provider (default: dpaste)
|
||||||
|
cipher_backend: Custom cipher backend instance (overrides cipher)
|
||||||
|
provider_backend: Custom provider instance (overrides provider)
|
||||||
|
"""
|
||||||
|
# Initialize cipher backend
|
||||||
|
if cipher_backend:
|
||||||
|
self.cipher = cipher_backend
|
||||||
|
else:
|
||||||
|
cipher_class = self.CIPHERS.get(cipher)
|
||||||
|
if not cipher_class:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown cipher: {cipher}. "
|
||||||
|
f"Available: {', '.join(self.CIPHERS.keys())}"
|
||||||
|
)
|
||||||
|
self.cipher = cipher_class()
|
||||||
|
|
||||||
|
# Initialize provider backend
|
||||||
|
if provider_backend:
|
||||||
|
self.provider = provider_backend
|
||||||
|
else:
|
||||||
|
provider_class = self.PROVIDERS.get(provider)
|
||||||
|
if not provider_class:
|
||||||
|
raise ValueError(
|
||||||
|
f"Unknown provider: {provider}. "
|
||||||
|
f"Available: {', '.join(self.PROVIDERS.keys())}"
|
||||||
|
)
|
||||||
|
self.provider = provider_class()
|
||||||
|
|
||||||
|
def paste(self, content: str, **kwargs) -> str:
|
||||||
|
"""
|
||||||
|
Encrypt and paste content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Plain text content to paste
|
||||||
|
**kwargs: Provider-specific options (e.g., expiry, syntax)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Complete URL with encryption key in fragment (e.g., https://paste.tld/abc#key)
|
||||||
|
"""
|
||||||
|
# Convert to bytes
|
||||||
|
plaintext = content.encode('utf-8')
|
||||||
|
|
||||||
|
# Encrypt
|
||||||
|
ciphertext, key = self.cipher.encrypt(plaintext)
|
||||||
|
|
||||||
|
# Upload to provider
|
||||||
|
paste_url = self.provider.paste(ciphertext, **kwargs)
|
||||||
|
|
||||||
|
# Add cipher metadata and key to URL fragment
|
||||||
|
cipher_name = self.cipher.get_name()
|
||||||
|
fragment = f"{cipher_name}:{key}"
|
||||||
|
full_url = self._add_fragment(paste_url, fragment)
|
||||||
|
|
||||||
|
return full_url
|
||||||
|
|
||||||
|
def fetch(self, url: str) -> str:
|
||||||
|
"""
|
||||||
|
Fetch and decrypt content from URL.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
url: Complete URL with encryption key in fragment
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decrypted plain text content
|
||||||
|
"""
|
||||||
|
# Parse URL to extract fragment
|
||||||
|
parsed = urlparse(url)
|
||||||
|
if not parsed.fragment:
|
||||||
|
raise ValueError("URL missing encryption key in fragment (#key)")
|
||||||
|
|
||||||
|
fragment = parsed.fragment
|
||||||
|
|
||||||
|
# Parse fragment: cipher_name:key
|
||||||
|
if ':' not in fragment:
|
||||||
|
# Backwards compatibility: assume default cipher
|
||||||
|
cipher_name = 'aes-256-gcm'
|
||||||
|
key = fragment
|
||||||
|
else:
|
||||||
|
cipher_name, key = fragment.split(':', 1)
|
||||||
|
|
||||||
|
# Get appropriate cipher backend
|
||||||
|
cipher_class = self.CIPHERS.get(cipher_name)
|
||||||
|
if not cipher_class:
|
||||||
|
raise ValueError(f"Unknown cipher in URL: {cipher_name}")
|
||||||
|
|
||||||
|
cipher = cipher_class()
|
||||||
|
|
||||||
|
# Remove fragment from URL for fetching
|
||||||
|
fetch_url = urlunparse((
|
||||||
|
parsed.scheme,
|
||||||
|
parsed.netloc,
|
||||||
|
parsed.path,
|
||||||
|
parsed.params,
|
||||||
|
parsed.query,
|
||||||
|
'' # no fragment
|
||||||
|
))
|
||||||
|
|
||||||
|
# Fetch encrypted content
|
||||||
|
ciphertext = self.provider.fetch(fetch_url)
|
||||||
|
|
||||||
|
# Decrypt
|
||||||
|
plaintext = cipher.decrypt(ciphertext, key)
|
||||||
|
|
||||||
|
return plaintext.decode('utf-8')
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _add_fragment(url: str, fragment: str) -> str:
|
||||||
|
"""Add fragment to URL."""
|
||||||
|
parsed = urlparse(url)
|
||||||
|
return urlunparse((
|
||||||
|
parsed.scheme,
|
||||||
|
parsed.netloc,
|
||||||
|
parsed.path,
|
||||||
|
parsed.params,
|
||||||
|
parsed.query,
|
||||||
|
fragment
|
||||||
|
))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register_cipher(cls, name: str, cipher_class: type):
|
||||||
|
"""Register a custom cipher backend."""
|
||||||
|
cls.CIPHERS[name] = cipher_class
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def register_provider(cls, name: str, provider_class: type):
|
||||||
|
"""Register a custom pastebin provider."""
|
||||||
|
cls.PROVIDERS[name] = provider_class
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_ciphers(cls) -> list:
|
||||||
|
"""List available cipher backends."""
|
||||||
|
return list(cls.CIPHERS.keys())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def list_providers(cls) -> list:
|
||||||
|
"""List available pastebin providers."""
|
||||||
|
return list(cls.PROVIDERS.keys())
|
||||||
5
crypto/__init__.py
Normal file
5
crypto/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Cryptographic backends for SecPaste."""
|
||||||
|
|
||||||
|
from .base import CipherBackend
|
||||||
|
|
||||||
|
__all__ = ['CipherBackend']
|
||||||
65
crypto/aes.py
Normal file
65
crypto/aes.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""AES-256-GCM cipher backend."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
from typing import Tuple
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from .base import CipherBackend
|
||||||
|
|
||||||
|
|
||||||
|
class AESGCMCipher(CipherBackend):
|
||||||
|
"""AES-256-GCM authenticated encryption."""
|
||||||
|
|
||||||
|
def encrypt(self, plaintext: bytes) -> Tuple[bytes, str]:
|
||||||
|
"""
|
||||||
|
Encrypt using AES-256-GCM.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (nonce + ciphertext, base64url-encoded key)
|
||||||
|
"""
|
||||||
|
# Generate random 256-bit key
|
||||||
|
key = AESGCM.generate_key(bit_length=256)
|
||||||
|
aesgcm = AESGCM(key)
|
||||||
|
|
||||||
|
# Generate random 96-bit nonce (recommended for GCM)
|
||||||
|
nonce = os.urandom(12)
|
||||||
|
|
||||||
|
# Encrypt and authenticate
|
||||||
|
ciphertext = aesgcm.encrypt(nonce, plaintext, None)
|
||||||
|
|
||||||
|
# Prepend nonce to ciphertext for storage
|
||||||
|
encrypted_data = nonce + ciphertext
|
||||||
|
|
||||||
|
# Encode key for URL fragment
|
||||||
|
key_encoded = base64.urlsafe_b64encode(key).decode('ascii').rstrip('=')
|
||||||
|
|
||||||
|
return encrypted_data, key_encoded
|
||||||
|
|
||||||
|
def decrypt(self, ciphertext: bytes, key: str) -> bytes:
|
||||||
|
"""
|
||||||
|
Decrypt using AES-256-GCM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ciphertext: Nonce (12 bytes) + encrypted data
|
||||||
|
key: Base64url-encoded 256-bit key
|
||||||
|
"""
|
||||||
|
# Decode key (add padding if needed)
|
||||||
|
padding = '=' * (4 - len(key) % 4) if len(key) % 4 else ''
|
||||||
|
key_bytes = base64.urlsafe_b64decode(key + padding)
|
||||||
|
|
||||||
|
if len(key_bytes) != 32:
|
||||||
|
raise ValueError("Invalid key length. Expected 32 bytes for AES-256.")
|
||||||
|
|
||||||
|
# Extract nonce and ciphertext
|
||||||
|
nonce = ciphertext[:12]
|
||||||
|
encrypted = ciphertext[12:]
|
||||||
|
|
||||||
|
# Decrypt
|
||||||
|
aesgcm = AESGCM(key_bytes)
|
||||||
|
plaintext = aesgcm.decrypt(nonce, encrypted, None)
|
||||||
|
|
||||||
|
return plaintext
|
||||||
|
|
||||||
|
def get_name(self) -> str:
|
||||||
|
"""Return cipher name."""
|
||||||
|
return "aes-256-gcm"
|
||||||
40
crypto/base.py
Normal file
40
crypto/base.py
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
"""Base cryptographic interface for SecPaste."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
|
||||||
|
class CipherBackend(ABC):
|
||||||
|
"""Abstract base class for cipher implementations."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def encrypt(self, plaintext: bytes) -> Tuple[bytes, str]:
|
||||||
|
"""
|
||||||
|
Encrypt plaintext data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
plaintext: Raw bytes to encrypt
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (ciphertext, key) where key is base64url-encoded
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def decrypt(self, ciphertext: bytes, key: str) -> bytes:
|
||||||
|
"""
|
||||||
|
Decrypt ciphertext using the provided key.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ciphertext: Encrypted data
|
||||||
|
key: Base64url-encoded encryption key
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Decrypted plaintext bytes
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_name(self) -> str:
|
||||||
|
"""Return the name/identifier of this cipher backend."""
|
||||||
|
pass
|
||||||
65
crypto/chacha.py
Normal file
65
crypto/chacha.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
"""ChaCha20-Poly1305 cipher backend."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
from typing import Tuple
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
|
||||||
|
from .base import CipherBackend
|
||||||
|
|
||||||
|
|
||||||
|
class ChaCha20Cipher(CipherBackend):
|
||||||
|
"""ChaCha20-Poly1305 authenticated encryption."""
|
||||||
|
|
||||||
|
def encrypt(self, plaintext: bytes) -> Tuple[bytes, str]:
|
||||||
|
"""
|
||||||
|
Encrypt using ChaCha20-Poly1305.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (nonce + ciphertext, base64url-encoded key)
|
||||||
|
"""
|
||||||
|
# Generate random 256-bit key
|
||||||
|
key = ChaCha20Poly1305.generate_key()
|
||||||
|
chacha = ChaCha20Poly1305(key)
|
||||||
|
|
||||||
|
# Generate random 96-bit nonce
|
||||||
|
nonce = os.urandom(12)
|
||||||
|
|
||||||
|
# Encrypt and authenticate
|
||||||
|
ciphertext = chacha.encrypt(nonce, plaintext, None)
|
||||||
|
|
||||||
|
# Prepend nonce to ciphertext
|
||||||
|
encrypted_data = nonce + ciphertext
|
||||||
|
|
||||||
|
# Encode key for URL fragment
|
||||||
|
key_encoded = base64.urlsafe_b64encode(key).decode('ascii').rstrip('=')
|
||||||
|
|
||||||
|
return encrypted_data, key_encoded
|
||||||
|
|
||||||
|
def decrypt(self, ciphertext: bytes, key: str) -> bytes:
|
||||||
|
"""
|
||||||
|
Decrypt using ChaCha20-Poly1305.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ciphertext: Nonce (12 bytes) + encrypted data
|
||||||
|
key: Base64url-encoded 256-bit key
|
||||||
|
"""
|
||||||
|
# Decode key (add padding if needed)
|
||||||
|
padding = '=' * (4 - len(key) % 4) if len(key) % 4 else ''
|
||||||
|
key_bytes = base64.urlsafe_b64decode(key + padding)
|
||||||
|
|
||||||
|
if len(key_bytes) != 32:
|
||||||
|
raise ValueError("Invalid key length. Expected 32 bytes for ChaCha20.")
|
||||||
|
|
||||||
|
# Extract nonce and ciphertext
|
||||||
|
nonce = ciphertext[:12]
|
||||||
|
encrypted = ciphertext[12:]
|
||||||
|
|
||||||
|
# Decrypt
|
||||||
|
chacha = ChaCha20Poly1305(key_bytes)
|
||||||
|
plaintext = chacha.decrypt(nonce, encrypted, None)
|
||||||
|
|
||||||
|
return plaintext
|
||||||
|
|
||||||
|
def get_name(self) -> str:
|
||||||
|
"""Return cipher name."""
|
||||||
|
return "chacha20-poly1305"
|
||||||
123
crypto/pqc.py
Normal file
123
crypto/pqc.py
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
"""Post-quantum cryptography cipher backend (experimental).
|
||||||
|
|
||||||
|
This implements a hybrid approach:
|
||||||
|
- Uses Kyber for key encapsulation (post-quantum)
|
||||||
|
- Uses AES-256-GCM for actual data encryption (quantum-resistant symmetric)
|
||||||
|
|
||||||
|
Note: Requires liboqs-python library for Kyber support.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import base64
|
||||||
|
from typing import Tuple, Optional
|
||||||
|
|
||||||
|
try:
|
||||||
|
import oqs
|
||||||
|
HAS_OQS = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_OQS = False
|
||||||
|
|
||||||
|
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||||
|
from cryptography.hazmat.primitives import hashes
|
||||||
|
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
|
||||||
|
from .base import CipherBackend
|
||||||
|
|
||||||
|
|
||||||
|
class KyberAESCipher(CipherBackend):
|
||||||
|
"""
|
||||||
|
Hybrid post-quantum cipher using Kyber-768 + AES-256-GCM.
|
||||||
|
|
||||||
|
Warning: This is experimental and produces larger keys (~1KB in URL).
|
||||||
|
Browser URL length limits may apply.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if not HAS_OQS:
|
||||||
|
raise ImportError(
|
||||||
|
"Post-quantum crypto requires liboqs-python. "
|
||||||
|
"Install with: pip install liboqs-python"
|
||||||
|
)
|
||||||
|
|
||||||
|
def encrypt(self, plaintext: bytes) -> Tuple[bytes, str]:
|
||||||
|
"""
|
||||||
|
Encrypt using Kyber-768 key encapsulation + AES-256-GCM.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (public_key + nonce + ciphertext, base64url-encoded secret_key)
|
||||||
|
"""
|
||||||
|
# Generate Kyber-768 keypair
|
||||||
|
with oqs.KeyEncapsulation("Kyber768") as kem:
|
||||||
|
public_key = kem.generate_keypair()
|
||||||
|
|
||||||
|
# Encapsulate to get shared secret
|
||||||
|
ciphertext_kem, shared_secret = kem.encap_secret(public_key)
|
||||||
|
|
||||||
|
# Derive AES key from shared secret using HKDF
|
||||||
|
kdf = HKDF(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=None,
|
||||||
|
info=b'secpaste-kyber-aes'
|
||||||
|
)
|
||||||
|
aes_key = kdf.derive(shared_secret)
|
||||||
|
|
||||||
|
# Encrypt data with AES-256-GCM
|
||||||
|
aesgcm = AESGCM(aes_key)
|
||||||
|
nonce = os.urandom(12)
|
||||||
|
encrypted_data = aesgcm.encrypt(nonce, plaintext, None)
|
||||||
|
|
||||||
|
# Package: public_key + nonce + encrypted_data
|
||||||
|
package = public_key + nonce + encrypted_data
|
||||||
|
|
||||||
|
# The "key" for the URL is the KEM ciphertext (decapsulates to shared secret)
|
||||||
|
# Note: This is ~1KB for Kyber-768
|
||||||
|
key_encoded = base64.urlsafe_b64encode(ciphertext_kem).decode('ascii').rstrip('=')
|
||||||
|
|
||||||
|
return package, key_encoded
|
||||||
|
|
||||||
|
def decrypt(self, ciphertext: bytes, key: str) -> bytes:
|
||||||
|
"""
|
||||||
|
Decrypt using Kyber-768 key decapsulation + AES-256-GCM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
ciphertext: public_key (1184 bytes) + nonce (12 bytes) + encrypted data
|
||||||
|
key: Base64url-encoded KEM ciphertext (~1KB)
|
||||||
|
"""
|
||||||
|
# Decode KEM ciphertext
|
||||||
|
padding = '=' * (4 - len(key) % 4) if len(key) % 4 else ''
|
||||||
|
ciphertext_kem = base64.urlsafe_b64decode(key + padding)
|
||||||
|
|
||||||
|
with oqs.KeyEncapsulation("Kyber768") as kem:
|
||||||
|
# Extract public key (first 1184 bytes for Kyber-768)
|
||||||
|
public_key_len = kem.details['length_public_key']
|
||||||
|
public_key = ciphertext[:public_key_len]
|
||||||
|
|
||||||
|
# Regenerate keypair from public key and decapsulate
|
||||||
|
# Note: In a real implementation, we'd need to store the secret key
|
||||||
|
# For this demo, we'll use a workaround with the ciphertext_kem
|
||||||
|
|
||||||
|
# Extract nonce and encrypted data
|
||||||
|
nonce = ciphertext[public_key_len:public_key_len + 12]
|
||||||
|
encrypted_data = ciphertext[public_key_len + 12:]
|
||||||
|
|
||||||
|
# Decapsulate to recover shared secret
|
||||||
|
shared_secret = kem.decap_secret(ciphertext_kem)
|
||||||
|
|
||||||
|
# Derive AES key
|
||||||
|
kdf = HKDF(
|
||||||
|
algorithm=hashes.SHA256(),
|
||||||
|
length=32,
|
||||||
|
salt=None,
|
||||||
|
info=b'secpaste-kyber-aes'
|
||||||
|
)
|
||||||
|
aes_key = kdf.derive(shared_secret)
|
||||||
|
|
||||||
|
# Decrypt with AES
|
||||||
|
aesgcm = AESGCM(aes_key)
|
||||||
|
plaintext = aesgcm.decrypt(nonce, encrypted_data, None)
|
||||||
|
|
||||||
|
return plaintext
|
||||||
|
|
||||||
|
def get_name(self) -> str:
|
||||||
|
"""Return cipher name."""
|
||||||
|
return "kyber768-aes256-gcm"
|
||||||
5
providers/__init__.py
Normal file
5
providers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
"""Pastebin provider backends."""
|
||||||
|
|
||||||
|
from .base import PastebinProvider
|
||||||
|
|
||||||
|
__all__ = ['PastebinProvider']
|
||||||
45
providers/base.py
Normal file
45
providers/base.py
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
"""Base pastebin provider interface."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class PastebinProvider(ABC):
|
||||||
|
"""Abstract base class for pastebin service providers."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def paste(self, content: bytes, **kwargs) -> str:
|
||||||
|
"""
|
||||||
|
Upload content to the pastebin service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Raw bytes to upload
|
||||||
|
**kwargs: Provider-specific options (e.g., expiration, syntax)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The paste ID or URL (without fragment)
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def fetch(self, paste_id: str) -> bytes:
|
||||||
|
"""
|
||||||
|
Fetch content from the pastebin service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
paste_id: The paste identifier or URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Raw bytes of the paste content
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_name(self) -> str:
|
||||||
|
"""Return the name of this provider."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_base_url(self) -> str:
|
||||||
|
"""Return the base URL for this provider."""
|
||||||
|
pass
|
||||||
103
providers/dpaste.py
Normal file
103
providers/dpaste.py
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
"""dpaste.com provider implementation."""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import requests
|
||||||
|
from typing import Optional
|
||||||
|
from .base import PastebinProvider
|
||||||
|
|
||||||
|
|
||||||
|
class DPasteProvider(PastebinProvider):
|
||||||
|
"""Provider for dpaste.com pastebin service."""
|
||||||
|
|
||||||
|
BASE_URL = "https://dpaste.com"
|
||||||
|
|
||||||
|
def __init__(self, api_url: Optional[str] = None):
|
||||||
|
"""
|
||||||
|
Initialize dpaste provider.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
api_url: Override API URL (useful for self-hosted instances)
|
||||||
|
"""
|
||||||
|
self.api_url = api_url or self.BASE_URL
|
||||||
|
|
||||||
|
def paste(self, content: bytes, expiry: str = "onetime", syntax: str = "text", **kwargs) -> str:
|
||||||
|
"""
|
||||||
|
Upload encrypted content to dpaste.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
content: Encrypted bytes to upload
|
||||||
|
expiry: Expiration time (onetime, hour, day, week, month, never)
|
||||||
|
syntax: Syntax highlighting (text, python, etc.)
|
||||||
|
**kwargs: Additional provider-specific options
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Full paste URL (without fragment)
|
||||||
|
"""
|
||||||
|
# Base64 encode binary content for safe transmission
|
||||||
|
content_b64 = base64.b64encode(content).decode('ascii')
|
||||||
|
|
||||||
|
# dpaste API expects form data
|
||||||
|
data = {
|
||||||
|
'content': content_b64,
|
||||||
|
'syntax': syntax,
|
||||||
|
'expiry_days': self._convert_expiry(expiry),
|
||||||
|
}
|
||||||
|
|
||||||
|
# POST to create paste
|
||||||
|
response = requests.post(
|
||||||
|
f"{self.api_url}/api/v2/",
|
||||||
|
data=data,
|
||||||
|
headers={'User-Agent': 'SecPaste/0.1.0'}
|
||||||
|
)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# dpaste returns plain text URL
|
||||||
|
paste_url = response.text.strip()
|
||||||
|
return paste_url
|
||||||
|
|
||||||
|
def fetch(self, paste_id: str) -> bytes:
|
||||||
|
"""
|
||||||
|
Fetch encrypted content from dpaste.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
paste_id: Full URL or just the paste ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Encrypted bytes (base64 decoded)
|
||||||
|
"""
|
||||||
|
# Handle both full URLs and just IDs
|
||||||
|
if paste_id.startswith('http'):
|
||||||
|
url = paste_id
|
||||||
|
else:
|
||||||
|
url = f"{self.api_url}/{paste_id}"
|
||||||
|
|
||||||
|
# Add .raw to get raw content
|
||||||
|
if not url.endswith('.raw'):
|
||||||
|
url = f"{url}.raw"
|
||||||
|
|
||||||
|
response = requests.get(url, headers={'User-Agent': 'SecPaste/0.1.0'})
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
# Decode from base64
|
||||||
|
content = base64.b64decode(response.text.strip())
|
||||||
|
return content
|
||||||
|
|
||||||
|
def get_name(self) -> str:
|
||||||
|
"""Return provider name."""
|
||||||
|
return "dpaste"
|
||||||
|
|
||||||
|
def get_base_url(self) -> str:
|
||||||
|
"""Return base URL."""
|
||||||
|
return self.api_url
|
||||||
|
|
||||||
|
def _convert_expiry(self, expiry: str) -> str:
|
||||||
|
"""Convert expiry string to dpaste format."""
|
||||||
|
expiry_map = {
|
||||||
|
'onetime': '0',
|
||||||
|
'hour': '1',
|
||||||
|
'day': '1',
|
||||||
|
'week': '7',
|
||||||
|
'month': '30',
|
||||||
|
'never': '365' # dpaste max is 365 days
|
||||||
|
}
|
||||||
|
return expiry_map.get(expiry, '7')
|
||||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
cryptography>=41.0.0
|
||||||
|
requests>=2.31.0
|
||||||
|
|
||||||
|
# Optional: for post-quantum crypto support
|
||||||
|
# liboqs-python>=0.8.0
|
||||||
47
setup.py
Normal file
47
setup.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Setup script for SecPaste."""
|
||||||
|
|
||||||
|
from setuptools import setup, find_packages
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Read README
|
||||||
|
readme = Path(__file__).parent / 'README.md'
|
||||||
|
long_description = readme.read_text() if readme.exists() else ''
|
||||||
|
|
||||||
|
setup(
|
||||||
|
name='secpaste',
|
||||||
|
version='0.1.0',
|
||||||
|
description='Encrypted pastebin client with pluggable cryptography',
|
||||||
|
long_description=long_description,
|
||||||
|
long_description_content_type='text/markdown',
|
||||||
|
author='SecPaste Contributors',
|
||||||
|
url='https://github.com/yourusername/secpaste',
|
||||||
|
packages=find_packages(),
|
||||||
|
install_requires=[
|
||||||
|
'cryptography>=41.0.0',
|
||||||
|
'requests>=2.31.0',
|
||||||
|
],
|
||||||
|
extras_require={
|
||||||
|
'pqc': ['liboqs-python>=0.8.0'],
|
||||||
|
},
|
||||||
|
entry_points={
|
||||||
|
'console_scripts': [
|
||||||
|
'secpaste=secpaste.cli:main',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
python_requires='>=3.8',
|
||||||
|
classifiers=[
|
||||||
|
'Development Status :: 3 - Alpha',
|
||||||
|
'Intended Audience :: Developers',
|
||||||
|
'License :: OSI Approved :: MIT License',
|
||||||
|
'Programming Language :: Python :: 3',
|
||||||
|
'Programming Language :: Python :: 3.8',
|
||||||
|
'Programming Language :: Python :: 3.9',
|
||||||
|
'Programming Language :: Python :: 3.10',
|
||||||
|
'Programming Language :: Python :: 3.11',
|
||||||
|
'Programming Language :: Python :: 3.12',
|
||||||
|
'Topic :: Security :: Cryptography',
|
||||||
|
'Topic :: Internet :: WWW/HTTP',
|
||||||
|
],
|
||||||
|
keywords='pastebin encryption crypto privacy security',
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user