GitHub - queelius/cryptoid: Client-side encrypted content for Hugo static sites with multi-user access control

Client-side encrypted content for Hugo static sites with multi-user access control. Pages marked for encryption are processed at build time with AES-256-GCM and per-user key wrapping. Visitors enter a username and password in the browser to decrypt and view content. Zero server-side components.

Motivation

You want to host private or protected content on a static site without any server-side backend. Maybe you have medical records, internal documentation, family-only content, or team notes that should be password-protected.

Existing solutions either require a backend server, use simple password-per-page approaches with no user management, or aren't well-integrated with Hugo. Cryptoid gives you real multi-user access control with groups, cascade encryption for entire directories, per-user credentials, and all while keeping the simplicity of a static site. Content is encrypted at build time and decrypted in the browser — your host (GitHub Pages, Netlify, etc.) never sees plaintext.

Installation

Quick Start

1. Create configuration file

Use the interactive setup:

Or create .cryptoid.yaml manually in your Hugo site root:

users:
  alice: "strong-passphrase-for-alice"
  bob: "strong-passphrase-for-bob"
  carol: "strong-passphrase-for-carol"

groups:
  admin: [alice]           # admin group gets access to ALL encrypted content
  team: [alice, bob, carol]
  members: [bob, carol]

salt: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"  # 32 hex chars, generated by init

Add .cryptoid.yaml to your .gitignore.

2. Mark content for encryption

Protect a directory (creates _index.md cascade):

cryptoid protect content/private/ --groups team

Or protect individual files:

cryptoid protect content/secret-post.md --groups admin --hint "Ask the team lead"

Or add encrypted: true directly to front matter:

---
title: "Secret Notes"
encrypted: true
groups: ["team"]
password_hint: "The usual"
remember: "ask"
---

Your secret content here...

3. Set up Hugo integration

Or manually copy the files:

cryptoid hugo status  # check what's installed

4. Build workflow

# Validate configuration
cryptoid validate --content-dir content/

# Encrypt marked pages
cryptoid encrypt --content-dir content/

# Build your site
hugo

# Restore plaintext for git
cryptoid decrypt --content-dir content/

How It Works

Multi-User Key Wrapping

Each encrypted page uses a random Content Encryption Key (CEK). The CEK is then wrapped (encrypted) individually for each authorized user using a key derived from "username:password" via PBKDF2. This means:

  • Adding/removing users only requires re-wrapping the CEK, not re-encrypting content
  • Each user has their own credentials — no shared passwords
  • The browser only needs one PBKDF2 derivation per login attempt

Cascade Encryption

Encryption settings propagate from _index.md files to all content in that directory and subdirectories:

content/
  _index.md              # encrypted: true, groups: ["all"]
  public-post.md         # encrypted: false  (explicit opt-out)
  private/
    _index.md            # encrypted: true, groups: ["team"]  (overrides parent)
    notes.md             # inherits: encrypted for team + admin
    internal/
      _index.md          # encrypted: true, groups: ["admin"]  (overrides)
      strategy.md        # inherits: encrypted for admin only

Rules:

  • Nearest _index.md with encrypted field wins
  • encrypted: false on a file overrides any cascade
  • _index.md bodies are encrypted like any other file; only the front matter stays readable for cascade resolution
  • A file's groups replaces inherited groups (no merging)

Admin Group

If a group named admin exists in your config, its members automatically get access to all encrypted content regardless of which groups a file specifies.

CLI Reference

Content Configuration

# Interactive mode: browse and select files/directories to protect/unprotect
cryptoid protect -i [--config PATH]

# Mark a directory for encryption (creates/updates _index.md)
cryptoid protect content/private/ --groups team

# Mark a file for encryption
cryptoid protect content/secret.md --groups admin --hint "Ask the lead" --remember session

# Remove encryption from a directory
cryptoid unprotect content/private/

# Remove encryption from a file (sets encrypted: false as cascade override)
cryptoid unprotect content/secret.md

Crypto Operations

# Encrypt all marked content
cryptoid encrypt --content-dir content/ --config .cryptoid.yaml

# Preview what would be encrypted
cryptoid encrypt --content-dir content/ --dry-run

# Decrypt all encrypted content
cryptoid decrypt --content-dir content/ --config .cryptoid.yaml

Configuration Management

# Initialize a new config file (interactive setup)
cryptoid init [--config .cryptoid.yaml]

# Show config file location and status
cryptoid config status

# Display full config file with source annotations
cryptoid config show [--config PATH] [--show-passwords]

# Validate config and content consistency
cryptoid config validate --content-dir content/ [--config PATH]

# Manage users
cryptoid config list-users [--config PATH]
cryptoid config add-user [--config PATH]
cryptoid config remove-user USERNAME [--config PATH]

# Manage groups
cryptoid config list-groups [--config PATH]
cryptoid config add-group GROUP_NAME [--config PATH]
cryptoid config remove-group GROUP_NAME [--config PATH]
cryptoid config add-to-group GROUP_NAME USERNAME [--config PATH]
cryptoid config remove-from-group GROUP_NAME USERNAME [--config PATH]

# Generate or manage salt
cryptoid config generate-salt [--apply] [--global]

Maintenance

# Show encryption status of all files
cryptoid status --content-dir content/ --config .cryptoid.yaml
cryptoid status --verbose  # include group/user statistics

# Re-wrap keys after changing users/groups (no decrypt needed)
cryptoid rewrap --content-dir content/ --config .cryptoid.yaml

# Re-wrap with new CEK for forward secrecy (after removing a user)
cryptoid rewrap --content-dir content/ --rekey

Hugo Integration

cryptoid hugo status              # check installation
cryptoid hugo install             # install shortcode + JS
cryptoid hugo uninstall           # remove cryptoid files

Configuration

.cryptoid.yaml

users:
  alice: "alice-password"
  bob: "bob-password"

groups:
  admin: [alice]              # special: universal access
  team: [alice, bob]

# Optional: shared PBKDF2 salt (32 hex chars = 16 bytes)
# salt: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4"

Front Matter Options

Field Type Default Description
encrypted bool false Enable encryption for this page
groups list all users Group names that get access
password_hint string null Hint displayed on the login form
remember string "ask" Credential storage behavior

Remember Options

  • "none" -- Never store credentials, always prompt
  • "session" -- Store in sessionStorage (cleared when tab closes)
  • "local" -- Store in localStorage (persists across sessions)
  • "ask" -- Show checkbox, let visitor decide

Known Limitations

Despite strong encryption and multi-user management, cryptoid has a few important limitations:

External assets aren't encrypted: Images, PDFs, and other media referenced in an encrypted markdown file are NOT encrypted. If your markdown has ![xray](images/xray.png), the image is still publicly accessible at that URL. Only the markdown body text is encrypted.

Front matter / YAML header is visible: The front matter (title, date, groups, etc.) remains in plaintext because Hugo needs it for site building and cryptoid needs it for cascade resolution. So an attacker can see page titles and which groups have access.

Section list pages may reveal structure: Hugo's list pages and taxonomy pages may reveal the existence and titles of encrypted content even though the body is encrypted.

No protection for Hugo-generated metadata: RSS feeds, sitemaps, search indexes, and other Hugo outputs may contain titles or excerpts from encrypted pages.

Despite these limitations, cryptoid is still excellent for hosting private content on a static site. It provides strong encryption for content bodies, proper multi-user management, group-based access control, and all without any server infrastructure.

Security

Cryptographic Parameters

Parameter Value
Cipher AES-256-GCM
KDF PBKDF2-SHA256
Iterations 310,000
Salt 16 bytes (random per page)
IV 12 bytes (random per encryption)
Key wrapping AES-256-GCM per user
Auth Tag 128 bits
Content hash Truncated SHA-256 (128 bits)

Ciphertext Format

Base64-encoded JSON (v2):

{
  "v": 2,
  "alg": "aes-256-gcm",
  "kdf": "pbkdf2-sha256",
  "iter": 310000,
  "salt": "<base64 16 bytes>",
  "iv": "<base64 12-byte content IV>",
  "ct": "<base64 AES-GCM encrypted content>",
  "keys": [
    {"iv": "<base64 12-byte wrap IV>", "ct": "<base64 wrapped CEK>"},
    ...
  ]
}

Threat Model

What cryptoid protects against:

  • Casual browsing of protected content
  • Search engine indexing of encrypted content
  • Static analysis of your HTML files

What cryptoid does NOT protect against:

  • Weak passwords (use strong, unique passphrases)
  • Malicious JavaScript injection on your site
  • Someone with access to your .cryptoid.yaml
  • Shoulder surfing / screen capture

Development

pip install -e ".[dev]"
pytest tests/ -v
pytest tests/ --cov=cryptoid --cov-report=term-missing

License

MIT