Requires
pip install edof[crypto]
EDOF 4.0.1 supports AES-256-GCM document encryption with multi-level password protection and recovery keys. By default, documents are plain (no encryption) — encryption is opt-in and adds zero friction when you don’t use it.
import edof
from edof.crypto import EDIT, ADMIN
# Build a document
doc = edof.new(title="Confidential")
page = doc.add_page()
page.add_textbox(10, 10, 100, 12, "TOP SECRET")
# Set up multi-level passwords (writes recovery key to your safe!)
recovery_key = doc.set_password("admin", "ownerSecret")
doc.set_password("design", "designerPwd")
doc.set_password("edit", "editorPwd")
doc.set_password("fill", "templateUser")
print("RECOVERY KEY:", recovery_key) # 24-character key, shown ONLY ONCE
doc.save("secret.edof")
# Loading
doc = edof.load("secret.edof", password="editorPwd")
print(doc.permission_level) # Permission.EDIT
doc.can(ADMIN) # False
There are five hierarchical permission levels. Higher levels imply all lower levels.
| Level | Constant | Allows |
|---|---|---|
view |
VIEW |
Render, print, export. No modifications. |
fill |
FILL |
view + change variable values (template filling). |
edit |
EDIT |
fill + change object .text and rich-text run text. |
design |
DESIGN |
edit + change styles, fonts, colors, layout, structure (add/remove objects and pages). |
admin |
ADMIN |
design + manage passwords, recovery key, override per-object locks. |
Import them:
from edof.crypto import VIEW, FILL, EDIT, DESIGN, ADMIN, Permission
Permission is an IntEnum, so you can compare them directly:
if doc.permission_level >= EDIT:
print("Can edit text")
| Mode | What’s encrypted | What’s visible without password |
|---|---|---|
none (default) |
Nothing | Everything (plain ZIP) |
partial |
Object text, rich-text runs, image data, QR data, table cell text, variable values | Page count, page sizes, fonts, colors, layout, structure, document title |
full |
Everything | Only “encrypted=true” + KDF parameters + slot count |
Set the mode after enabling encryption:
doc.set_password("admin", "x")
doc.encryption_mode = "partial" # default after first password is "full"
doc.save("template.edof")
Use partial when you want to share a layout / template publicly, but the actual content is private. Example: an invoice template — anyone can see how it’s structured (placement of fields, fonts, branding), but the actual invoice numbers, prices, and customer data are encrypted.
Use full for private documents where even the existence of pages, layout, and the title should be hidden. Example: a confidential contract that’s stored on a shared drive.
If you load a partial document without supplying a password, it loads in a redacted view:
doc = edof.load("partial.edof") # no password
print(doc.title) # visible
print(doc.pages[0].objects[0].text)
# '█' — placeholder, real text is encrypted
The placeholder is a single █ character, replacing all sensitive text content. The user can see the layout and structure but no actual content.
This mode lets the editor render a “preview” of an encrypted template even without a password — useful in file managers, thumbnails, etc.
doc = edof.new()
recovery_key = doc.set_password("admin", "myMasterPassword")
When you set the first password on a document:
encryption_mode = "full".print(recovery_key)
# "7K3F-9XQM-2N8P-VR4A-HT6L-Z5BJ"
Subsequent set_password calls add slots without generating new recovery keys:
doc.set_password("design", "designerPwd") # returns None
doc.set_password("edit", "editorPwd") # returns None
doc.set_password("fill", "userPwd") # returns None
You can have any subset of the four levels — you don’t need all four. A common pattern:
admin only — the simplest case, single password gives full accessadmin + fill — owner can do everything, users can fill the templateadmin + edit + fill — adds an editor role for proofreadersadminadminadminThe reasoning: knowing the old password proves you were trusted at that level, so you can rotate it (e.g. the editor leaves, the owner gives the editor role to someone else by changing edit password).
doc = edof.load("secret.edof", password="editorPwd")
The library tries the password against every slot. The first match determines the granted permission.
If no slot matches → raises EdofWrongPassword.
If the file is encrypted but no password supplied → raises EdofPasswordRequired.
doc = edof.load("secret.edof", recovery_key="7K3F-9XQM-2N8P-VR4A-HT6L-Z5BJ")
The recovery key always grants ADMIN access. Useful when the admin password is lost.
After loading, check what’s allowed:
doc.permission_level # Permission.EDIT
doc.can(EDIT) # True
doc.can(DESIGN) # False
# Raises PermissionError if level is insufficient
doc.require(EDIT)
doc.require(DESIGN) # raises
can() is for branching logic; require() is for guarding operations that need a level.
Each object can override doc-level permissions for its modification:
heading = page.add_textbox(15, 15, 180, 18, "MUST NEVER CHANGE")
heading.lock_level = "design" # only design+ can modify
heading.lock_text = True # text NEVER editable, even by admin
lock_level raises the bar above the document’s general permission. A user with edit permission can change other text but not this heading.
lock_text = True is a hard text lock. Even ADMIN cannot change obj.text or obj.runs until they explicitly clear the flag (which requires ADMIN). Useful for “this header must never change accidentally”.
Helper methods on each object:
obj.can_modify(doc) # respects doc permission AND lock_level
obj.can_modify_text(doc) # also respects lock_text
The editor uses these to disable UI buttons and show “needs level password” dialogs.
To rotate a password (someone leaves, password compromised, periodic rotation policy):
doc = edof.load("secret.edof", password="oldEditor")
doc.change_password("edit", "oldEditor", "newEditor")
doc.save("secret.edof")
Note: change_password does not re-encrypt the bulk content. It just rewraps the slot key — fast even for huge documents.
doc = edof.load("secret.edof", password="adminPwd")
doc.remove_password("fill") # remove one slot
doc.clear_all_protection() # remove all encryption (becomes plain)
doc.save("plain.edof")
After clear_all_protection, the document is back to encryption_mode = "none" and saves as a plain ZIP. Of course, anyone who had a copy of the encrypted file can still decrypt their copy with their old password — clearing protection only affects future saves.
The recovery key is shown once when you set the first password. After that:
doc.recovery_key until you call consume_recovery_key() to clear itIf you lose all your passwords AND the recovery key, the document is mathematically unrecoverable. There is no backdoor.
recovery_key = doc.set_password("admin", "x")
print(recovery_key)
# Save it somewhere safe!
# Optional: clear from memory after copying
doc.consume_recovery_key()
The editor automatically shows a recovery key dialog with a “I have saved this key” confirmation gate.
You cannot generate a new recovery key for an existing protected document — it’s tied to the content key. If you want a fresh recovery key:
doc = edof.load("secret.edof", password="admin_pwd")
doc.clear_all_protection() # back to plain
new_recovery = doc.set_password("admin", "admin_pwd") # re-protect, new RK
doc.save("secret.edof")
This rotates everything — content key, all slots, recovery key.
For curious readers / security reviewers:
| Component | Specification |
|---|---|
| Symmetric cipher | AES-256-GCM |
| Key size | 256 bits |
| Nonce size | 96 bits (12 bytes), random per ciphertext |
| Auth tag size | 128 bits (16 bytes) |
| KDF | PBKDF2-HMAC-SHA256 |
| KDF iterations | 600,000 |
| Salt size | 128 bits (16 bytes), random per slot |
| Random source | Python’s secrets.token_bytes() (CSPRNG) |
| Recovery key alphabet | 32 chars: ABCDEFGHJKLMNPQRSTUVWXYZ23456789 (excludes I, O, 0, 1) |
| Recovery key length | 24 alphanumeric (~120 bits entropy) |
Each password slot in the manifest is a JSON object:
{
"permission": "edit",
"kdf": "pbkdf2-sha256",
"iterations": 600000,
"salt": "<base64, 16 bytes>",
"wrapped_key": "<base64 — AES-GCM encryption of content key with derived slot key>"
}
When unlocking, edof iterates over slots, derives the slot key from the password using the slot’s salt, attempts to decrypt the wrapped content key, and uses the GCM auth tag to verify the password is correct (no false positives).
A single 32-byte content key encrypts the actual document. Each password slot wraps a copy of the same content key — so the bulk content is encrypted only once. Changing one password rewraps just that slot, not the entire payload.
GCM provides authenticated encryption: any tampering with the ciphertext (even a single bit flip) makes decryption fail. So:
The PyQt6 editor (edof-editor) provides a UI for all encryption operations:
When opening a legacy EDOF 2 archive that had an XOR password, the editor offers to set up real AES-256 encryption.
# Setup
doc.set_password(level, password) → str | None # returns recovery key on first call
doc.change_password(level, old_pwd, new_pwd)
doc.remove_password(level)
doc.clear_all_protection()
# State
doc.encryption_mode # "none" | "partial" | "full"
doc.is_encrypted # bool
doc.is_locked # bool — encrypted but not unlocked
doc.permission_level # Permission enum
doc.password_levels # list of level names with passwords set
doc.recovery_key # str | None (pending, returned once)
doc.consume_recovery_key() # str | None — clear pending RK
# Unlock / lock
doc.unlock(password=..., recovery_key=...) # → Permission
doc.lock() # forget content key
# Permission checks
doc.can(level) → bool
doc.require(level) # raises if denied
obj.lock_level = "design" # str: "" | "fill" | "edit" | "design" | "admin"
obj.lock_text = True # bool
obj.can_modify(doc) → bool
obj.can_modify_text(doc) → bool
edof.load(path, password="...", recovery_key="...")
from edof.crypto import (
Permission, VIEW, FILL, EDIT, DESIGN, ADMIN,
EdofPasswordRequired, EdofWrongPassword, EdofCryptoError, EdofCryptoUnavailable,
HAS_CRYPTO, # bool — whether cryptography lib is available
generate_recovery_key, # generate a key without using it
normalize_recovery_key, # strip dashes / case
describe_permission, # → dict with "label", "allowed", "denied"
)