GUN101 PDF Encryption Protocol
Project description
GUN101 PDF Encryption Protocol
GUN101 is a production-ready Python library for encrypting PDF files with military-grade AES-256-GCM encryption, augmented by a nanosecond-precision time-based cryptographic challenge system. It protects your documents even when users choose weak passwords.
Table of Contents
- What is GUN101?
- Security Architecture
- Installation
- Quick Start
- CLI Usage
- Python API Reference
- Configuration
- Examples
- Running Tests
- Security Considerations
- Applying the GKP Rename — Files to Update
- License
What is GUN101?
GUN101 is a layered PDF encryption protocol built around a core insight: even if a password is stolen, the encrypted file should remain uncrackable. It does this by weaving nanosecond-precision timestamps into the encryption key itself.
Two encryption modes are supported:
| Mode | How it works | Best for |
|---|---|---|
| GUN101 (password-based) | User supplies a password; multi-layer key derivation hardens it | Personal files, shared secrets |
| GUN101-GKP (Ghost Key Protocol) | A random AES key is sealed with the recipient's RSA-4096 public key | Targeted delivery; no password needed |
Security Architecture
GUN101 stacks five independent security layers. Breaking the encryption requires defeating all of them simultaneously.
Layer 1 — Argon2 Password Hashing
The user's password is hashed with Argon2id — a memory-hard, GPU-resistant algorithm. This prevents rainbow table attacks and makes brute-force attempts extremely expensive.
Layer 2 — Dual-Pass PBKDF2 Key Derivation
The raw password is stretched through two sequential PBKDF2 passes:
- Pass 1: PBKDF2-HMAC-SHA256 at 100,000 iterations
- Pass 2: PBKDF2-HMAC-SHA512 at 10,000 iterations
This ensures the encryption key is deterministic (same password + salt = same key, every time) while being computationally costly to brute-force.
Layer 3 — GUN101 Time-Based Challenge
At the moment of encryption, a challenge is created from the current time captured at nanosecond precision. This challenge is salted and hashed (SHA-256), then stored alongside the ciphertext. During decryption, the challenge is re-verified — any tampering with the file causes this check to fail.
The timestamp component is also mixed into the final AES key, meaning the key varies with each encryption even if the password is identical. Reverse-engineering this requires knowing the exact nanosecond of encryption, which is impossible after the fact.
Layer 4 — AES-256-GCM Authenticated Encryption
Actual file content is encrypted with AES-256 in GCM mode. GCM provides both confidentiality (the content is hidden) and authenticity (any modification to the ciphertext is detected and rejected).
Layer 5 — Rate Limiting
Decryption attempts are tracked per file (by SHA-256 fingerprint). After 5 failed attempts, the file is locked for 15 minutes. Lockout state persists to disk in .security/lockout_log.json.
Installation
From PyPI (recommended):
pip install gun101
From source:
git clone https://github.com/25f3002130/GUN101.git
cd GUN101
pip install -e .
Requirements: Python 3.9+, cryptography>=42.0.2, argon2-cffi>=23.1.0
Note: The PyPI package name is
gun101(with hyphen), but the Python import name isgun101(no hyphen). This is standard Python packaging practice — hyphens are not valid in Python identifiers.
Quick Start
Password-Based Encryption
from gun101 import PDFEncryptionHandler
handler = PDFEncryptionHandler()
# Read your PDF
with open("document.pdf", "rb") as f:
pdf_data = f.read()
# Encrypt
encrypted = handler.encrypt_pdf(pdf_data, password="MySecurePassword!")
# Save encrypted file
with open("document.pdf.encrypted", "wb") as f:
f.write(encrypted)
# Decrypt later
with open("document.pdf.encrypted", "rb") as f:
encrypted_data = f.read()
decrypted_pdf, metadata = handler.decrypt_pdf(encrypted_data, password="MySecurePassword!")
with open("document_restored.pdf", "wb") as f:
f.write(decrypted_pdf)
GUN101-GKP (Ghost Key Protocol)
GUN101-GKP requires no shared password. The sender uses the recipient's public GKP Identity Token; only the recipient's physical device (which holds the matching private key) can decrypt the file.
Step 1 — Recipient generates their GKP identity (one-time setup):
from gun101.identity import IdentityManager
manager = IdentityManager()
token = manager.generate_identity() # optionally pass a passphrase
print("Share this token with senders:\n", token)
Step 2 — Sender encrypts for the recipient:
from gun101 import PDFEncryptionHandler
handler = PDFEncryptionHandler()
with open("document.pdf", "rb") as f:
pdf_data = f.read()
recipient_token = "..." # GKP Identity Token provided by the recipient
encrypted = handler.encrypt_pdf_for_recipient(pdf_data, recipient_token)
with open("document.pdf.encrypted", "wb") as f:
f.write(encrypted)
Step 3 — Recipient decrypts on their device:
from gun101 import PDFEncryptionHandler
handler = PDFEncryptionHandler()
with open("document.pdf.encrypted", "rb") as f:
encrypted_data = f.read()
decrypted_pdf, metadata = handler.decrypt_pdf_identity(encrypted_data)
with open("document_restored.pdf", "wb") as f:
f.write(decrypted_pdf)
CLI Usage
GUN101 ships with a gun101 command-line tool installed automatically with the package.
gun101 help Show all commands
gun101 encrypt <file> Encrypt a PDF with a password (GUN101)
gun101 encrypt <file> --recipient <TOKEN> Encrypt for a specific recipient (GUN101-GKP)
gun101 decrypt <file> Decrypt (auto-detects GUN101 vs GKP)
gun101 generate-identity Generate your GKP Identity Token
gun101 show-identity Display your GKP Identity Token
gun101 reset-identity Delete your GKP identity (irreversible)
Options:
| Flag | Description |
|---|---|
-o, --output PATH |
Custom output file path |
--recipient TOKEN |
Recipient's GKP Identity Token (enables GUN101-GKP mode) |
Examples:
# Password-based (GUN101) — prompted to enter and confirm the password
gun101 encrypt report.pdf
gun101 decrypt report.pdf.encrypted
# GUN101-GKP — no password needed
gun101 generate-identity
gun101 show-identity # copy the token and send it to the sender
gun101 encrypt report.pdf --recipient <GKP_TOKEN>
gun101 decrypt report.pdf.encrypted # auto-detects GKP mode
Python API Reference
PDFEncryptionHandler
The main entry point for all encryption and decryption operations.
from gun101 import PDFEncryptionHandler
handler = PDFEncryptionHandler()
encrypt_pdf(pdf_data, password, metadata=None) → bytes
Encrypts a PDF using password-based GUN101. Raises ValueError on wrong password, challenge failure, tampering, or lockout.
| Parameter | Type | Description |
|---|---|---|
pdf_data |
bytes |
Raw PDF file content |
password |
str |
User password |
metadata |
dict, optional |
Arbitrary key-value metadata to encrypt alongside the PDF |
Returns the encrypted container as bytes.
decrypt_pdf(encrypted_package, password) → tuple[bytes, dict | None]
Decrypts a password-locked GUN101 PDF.
| Parameter | Type | Description |
|---|---|---|
encrypted_package |
bytes |
Output of encrypt_pdf |
password |
str |
User password |
Returns (pdf_bytes, metadata_dict_or_None).
encrypt_pdf_for_recipient(pdf_data, recipient_token, metadata=None) → bytes
Encrypts a PDF using GUN101-GKP for a specific recipient. No password required. A random AES-256 key is generated and sealed using the recipient's RSA-4096 GKP Identity Token.
| Parameter | Type | Description |
|---|---|---|
pdf_data |
bytes |
Raw PDF file content |
recipient_token |
str |
Recipient's GKP Identity Token |
metadata |
dict, optional |
Arbitrary key-value metadata to encrypt alongside the PDF |
decrypt_pdf_identity(encrypted_package, passphrase=None) → tuple[bytes, dict | None]
Decrypts a GKP-locked PDF using this device's GKP private key.
| Parameter | Type | Description |
|---|---|---|
encrypted_package |
bytes |
Output of encrypt_pdf_for_recipient |
passphrase |
str, optional |
Passphrase if the GKP private key was protected at generation time |
IdentityManager
Manages RSA-4096 keypairs and GKP Identity Tokens.
from gun101.identity import IdentityManager
manager = IdentityManager()
| Method | Description |
|---|---|
generate_identity(passphrase=None) → str |
Generate a new GKP keypair; returns the public Identity Token |
get_identity_token() → str |
Retrieve the stored GKP Identity Token |
get_identity_fingerprint(token=None) → str |
Get a human-readable colon-separated fingerprint |
has_identity() → bool |
Check if a GKP identity exists on this device |
reset_identity() |
Delete the keypair from disk (irreversible) |
encrypt_symmetric_key(key, token) → bytes |
RSA-OAEP encrypt an AES key with a recipient's GKP token |
decrypt_symmetric_key(encrypted_key, passphrase=None) → bytes |
RSA-OAEP decrypt using local GKP private key |
GKP private keys are stored at ~/.gun101/private_key.pem with 0o600 permissions (owner read/write only).
CryptoEngine
Low-level AES-256-GCM encryption primitives.
from gun101 import CryptoEngine
engine = CryptoEngine()
| Method | Description |
|---|---|
encrypt(data, key, aad=None) → (nonce, ciphertext, tag) |
Encrypt; returns separate components |
decrypt(nonce, ciphertext, tag, key, aad=None) → bytes |
Decrypt and verify |
encrypt_to_bytes(data, key, aad=None) → bytes |
Encrypt to single byte string (nonce + ciphertext + tag) |
decrypt_from_bytes(encrypted, key, aad=None) → bytes |
Decrypt from single byte string |
KeyManager
Password hashing and key derivation.
from gun101 import KeyManager
km = KeyManager()
| Method | Description |
|---|---|
derive_key_from_password(password, salt=None) → (key, salt) |
PBKDF2 dual-pass key derivation |
hash_password(password) → str |
Argon2 hash for storage |
verify_password(password, argon2_hash) → bool |
Verify a password against stored Argon2 hash |
SecurityLayer
Rate-limiting and brute-force protection.
from gun101 import SecurityLayer
layer = SecurityLayer(max_attempts=5)
| Method | Description |
|---|---|
record_attempt(identifier, success=False) → (allowed, message) |
Record a decryption attempt; returns whether allowed to proceed |
is_locked(identifier) → bool |
Check if an identifier is currently locked out |
get_status(identifier) → dict |
Get attempt count, lock status, and remaining attempts |
reset_attempts(identifier) |
Manually reset attempt counter |
Configuration
Security parameters are centralized in SecurityConfig and available via the security_config singleton:
from gun101 import security_config
print(security_config.ENCRYPTION_PROTOCOL) # GUN101
print(security_config.GKP_PROTOCOL) # GUN101-GKP
print(security_config.ENCRYPTION_ALGORITHM) # AES-256-GCM
print(security_config.MAX_DECRYPTION_ATTEMPTS) # 5
print(security_config.LOCKOUT_DURATION) # 15 minutes
| Parameter | Default | Description |
|---|---|---|
ENCRYPTION_PROTOCOL |
"GUN101" |
Outer protocol identifier (all containers) |
GKP_PROTOCOL |
"GUN101-GKP" |
GKP sub-protocol identifier |
GKP_LOCK_MODE |
"GKP" |
Value stored in container's lock_mode field |
KEY_SIZE |
32 bytes | AES key length (256 bits) |
ARGON2_TIME_COST |
4 | Argon2 iteration count |
ARGON2_MEMORY_COST |
128 MB | Argon2 memory requirement |
ARGON2_PARALLELISM |
4 | Argon2 thread count |
KEY_STRETCH_ITERATIONS |
100,000 | PBKDF2 iterations (first pass) |
MAX_DECRYPTION_ATTEMPTS |
5 | Failed attempts before lockout |
LOCKOUT_DURATION |
15 minutes | Lockout duration |
TIMESTAMP_TOLERANCE |
300 seconds | Tolerance window for timestamp validation |
Examples
The examples/ directory contains ready-to-run scripts:
# Basic password-based encryption and decryption
python examples/basic_usage.py
# Advanced configuration and GUN101-GKP (Ghost Key Protocol)
python examples/advanced_config.py
Encrypt a real file in three lines:
from gun101 import PDFEncryptionHandler
handler = PDFEncryptionHandler()
open("out.encrypted","wb").write(handler.encrypt_pdf(open("doc.pdf","rb").read(), "password"))
Attach metadata to an encrypted file:
metadata = {"author": "Alice", "classification": "CONFIDENTIAL"}
encrypted = handler.encrypt_pdf(pdf_data, "password", metadata=metadata)
pdf, meta = handler.decrypt_pdf(encrypted, "password")
print(meta) # {'author': 'Alice', 'classification': 'CONFIDENTIAL'}
Running Tests
pip install -e ".[dev]"
pytest
pytest --cov=gun101 --cov-report=term-missing # with coverage
Tests cover encryption/decryption correctness, wrong-password rejection, rate limiting, GKP identity keypair operations, and GUN101 challenge verification.
Security Considerations
- Lost password: Password-locked files cannot be recovered without the original password. There is no back door.
- Lost GKP identity: If you delete your GKP identity (or lose the device), any GKP-encrypted files targeting that identity are permanently unrecoverable. Back up
~/.gun101/private_key.pembefore resetting. - GKP passphrase protection: When generating a GKP identity, supply a passphrase to encrypt the private key at rest on disk.
- Rate limiting persistence: Lockout state is written to
.security/lockout_log.jsonin the working directory. In server deployments, ensure this path is writable and persistent. - Timestamp validation: GUN101 verifies that the challenge timestamp in the encrypted metadata matches the one in the container. Any tampering with either field causes decryption to fail.
Applying the GKP Rename — Files to Update
The GUN101-GKP (Ghost Key Protocol) naming was introduced after the initial release. If you are applying this rename to an existing copy of the codebase, here are every file that needs to change and exactly what to update in each.
Note on backward compatibility: The outer
"protocol"field in all encrypted containers stays"GUN101". Only thelock_modevalue changes from"identity"→"GKP". This means any files already encrypted with the oldlock_mode: "identity"will fail to auto-detect correctly untilpdf_handler.pyandcli.pyare updated. Re-encrypt important files after the update if backward compatibility matters.
src/gun101/config.py
Add two new constants to the SecurityConfig class:
# GUN101-GKP: Ghost Key Protocol (identity/asymmetric sub-protocol)
GKP_PROTOCOL = "GUN101-GKP"
GKP_LOCK_MODE = "GKP" # value stored in the container's lock_mode field
This is the single source of truth for both strings. Every other file reads from security_config rather than hardcoding them.
src/gun101/pdf_handler.py
Three changes:
1. Module docstring — update the description of the identity-based mode to say "GUN101-GKP (Ghost Key Protocol)".
2. encrypt_pdf_for_recipient — the container dict currently writes "lock_mode": "identity". Replace with:
"sub_protocol": security_config.GKP_PROTOCOL, # "GUN101-GKP"
"lock_mode": security_config.GKP_LOCK_MODE, # "GKP"
Same change applies inside encryption_metadata (the dict that gets encrypted alongside the PDF).
3. decrypt_pdf and decrypt_pdf_identity — both methods check lock_mode to route or reject files. Update the string comparisons:
# decrypt_pdf — reject GKP files
if lock_mode == security_config.GKP_LOCK_MODE: # was: == "identity"
raise ValueError("This file is GKP-locked (Ghost Key Protocol)...")
# decrypt_pdf_identity — require GKP files
if lock_mode != security_config.GKP_LOCK_MODE: # was: != "identity"
raise ValueError("This file is password-locked (GUN101), not GKP-locked...")
src/gun101/identity.py
No functional changes — this file only needs its docstrings and comments updated. Rename every occurrence of "Identity Manager" / "identity-based" / "identity-locked" to use "GKP" / "Ghost Key Protocol" language. The class name IdentityManager and all method names stay the same.
src/gun101/cli.py
Two changes:
1. Auto-detect block in decrypt_command — currently compares lock_mode == "identity". Update to:
if lock_mode == security_config.GKP_LOCK_MODE:
2. User-facing text — update the banner, help strings, and info/success messages to say "GUN101-GKP" and "Ghost Key Protocol" where they currently say "identity-based" or "Identity Token". No command names change (generate-identity, show-identity, reset-identity stay as-is for CLI backward compatibility).
src/gun101/__init__.py
Add one new public constant:
__gkp_protocol__ = "GUN101-GKP"
Update the module docstring to mention both modes by name.
examples/basic_usage.py and examples/advanced_config.py
Update comments and print() strings that refer to "identity-based encryption" to say "GUN101-GKP (Ghost Key Protocol)". No functional code changes needed in the examples.
Files that do not need changes
| File | Reason |
|---|---|
crypto_engine.py |
Pure AES-256-GCM logic; protocol-agnostic |
key_manager.py |
Pure key derivation; no protocol references |
security_layer.py |
Pure rate-limiting; no protocol references |
utils.py |
Pure utility functions; no protocol references |
gun101_challenge.py |
The challenge system is shared by both modes; no GKP-specific references |
pyproject.toml |
Package metadata; no protocol strings |
tests/ |
Test logic uses method names, not protocol strings — no changes needed unless your tests assert on the lock_mode string value directly |
License
MIT License — see LICENSE for full terms.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file gun101-1.2.0.tar.gz.
File metadata
- Download URL: gun101-1.2.0.tar.gz
- Upload date:
- Size: 30.9 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1fde4c2c68e7cf8dfb0a51a7f9904a8e698f4c0810670ad29ca98bbfe317f2a2
|
|
| MD5 |
181d30ba19d4fd9124c2df89030361e4
|
|
| BLAKE2b-256 |
c12a46977939861d08f1f985f1ba64b4a02fc7b22cc1a5b4d9e9116973defe47
|
Provenance
The following attestation bundles were made for gun101-1.2.0.tar.gz:
Publisher:
publish.yml on dialga-cmd/GUN101
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
gun101-1.2.0.tar.gz -
Subject digest:
1fde4c2c68e7cf8dfb0a51a7f9904a8e698f4c0810670ad29ca98bbfe317f2a2 - Sigstore transparency entry: 1721916890
- Sigstore integration time:
-
Permalink:
dialga-cmd/GUN101@6236af32d904b920059da6e08f865674f915b0dd -
Branch / Tag:
refs/tags/v1.3.0 - Owner: https://github.com/dialga-cmd
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@6236af32d904b920059da6e08f865674f915b0dd -
Trigger Event:
release
-
Statement type:
File details
Details for the file gun101-1.2.0-py3-none-any.whl.
File metadata
- Download URL: gun101-1.2.0-py3-none-any.whl
- Upload date:
- Size: 27.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8d621f0741f1cf2fd810a0e365eeeb7652ed08e97921edfd0920de61cb1dec7f
|
|
| MD5 |
d71df0b8e21ff249157aa3b422c62761
|
|
| BLAKE2b-256 |
70716fbbc89a188a93587c254465560205cfc2f28492460165b9b2ac6dfb3672
|
Provenance
The following attestation bundles were made for gun101-1.2.0-py3-none-any.whl:
Publisher:
publish.yml on dialga-cmd/GUN101
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
gun101-1.2.0-py3-none-any.whl -
Subject digest:
8d621f0741f1cf2fd810a0e365eeeb7652ed08e97921edfd0920de61cb1dec7f - Sigstore transparency entry: 1721916992
- Sigstore integration time:
-
Permalink:
dialga-cmd/GUN101@6236af32d904b920059da6e08f865674f915b0dd -
Branch / Tag:
refs/tags/v1.3.0 - Owner: https://github.com/dialga-cmd
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@6236af32d904b920059da6e08f865674f915b0dd -
Trigger Event:
release
-
Statement type: