Created
May 19, 2026 13:53
-
-
Save le-dawg/d48575aca23984919e4ea9a97f9b1f31 to your computer and use it in GitHub Desktop.
localcrypt — macOS system-wide AES-256 text encryption via Services / keyboard shortcuts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/bin/bash | |
| # ============================================================================= | |
| # localcrypt — macOS system-wide text encryption via Services menu / shortcuts | |
| # ============================================================================= | |
| # | |
| # WHAT IT DOES | |
| # ------------ | |
| # Adds two entries to macOS Services (right-click menu + keyboard shortcuts): | |
| # | |
| # Encrypt (Cmd+Ctrl+Shift+E) | |
| # Select any text in any app → trigger → text disappears → | |
| # AES-256 ciphertext is in your clipboard → Cmd+V to paste it | |
| # | |
| # Decrypt (Cmd+Ctrl+Shift+D) | |
| # Select ciphertext in any app → trigger → ciphertext is replaced | |
| # by the original plaintext in-place (no Cmd+V needed) | |
| # | |
| # SECURITY MODEL | |
| # -------------- | |
| # Your passphrase is stored ONCE in ~/.localcrypt_key (chmod 600). | |
| # It is never entered again after install. Anyone with filesystem access | |
| # to your Mac could read it — this tool prioritises convenience, not | |
| # military-grade security. Good for keeping text private from colleagues | |
| # glancing at your screen or reading shared tools like Productive/Notion. | |
| # | |
| # ENCRYPTION | |
| # ---------- | |
| # AES-256-CBC, PBKDF2-SHA256, 100 000 iterations (openssl built into macOS). | |
| # The same passphrase on any Mac can decrypt text encrypted here. | |
| # | |
| # RECOVERY | |
| # -------- | |
| # If you lose ~/.localcrypt_key: re-run this script with the same passphrase | |
| # and all previously encrypted text will be decryptable again. | |
| # | |
| # REQUIREMENTS | |
| # ------------ | |
| # macOS 13 Ventura or later. No third-party dependencies. | |
| # | |
| # INSTALL | |
| # ------- | |
| # curl -fsSL <gist-url> | bash | |
| # or: | |
| # bash localcrypt-install.sh | |
| # | |
| # UNINSTALL | |
| # --------- | |
| # rm -rf ~/.localcrypt ~/.localcrypt_key | |
| # rm -rf ~/Library/Services/Encrypt.workflow ~/Library/Services/Decrypt.workflow | |
| # ============================================================================= | |
| set -euo pipefail | |
| # ── Colour helpers ──────────────────────────────────────────────────────────── | |
| RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' | |
| ok() { printf "${GREEN}✓${NC} %s\n" "$*"; } | |
| warn() { printf "${YELLOW}!${NC} %s\n" "$*"; } | |
| die() { printf "${RED}✗${NC} %s\n" "$*" >&2; exit 1; } | |
| echo "" | |
| echo " localcrypt installer" | |
| echo " ════════════════════" | |
| echo "" | |
| # ── Preflight checks ────────────────────────────────────────────────────────── | |
| [[ "$(uname)" == "Darwin" ]] || die "macOS only" | |
| [[ $(sw_vers -productVersion | cut -d. -f1) -ge 13 ]] || die "macOS 13+ required" | |
| command -v openssl &>/dev/null || die "openssl not found" | |
| command -v osascript &>/dev/null || die "osascript not found" | |
| ok "macOS $(sw_vers -productVersion) — all prerequisites met" | |
| # ── Passphrase setup ────────────────────────────────────────────────────────── | |
| KEY_FILE="$HOME/.localcrypt_key" | |
| if [[ -f "$KEY_FILE" ]]; then | |
| warn "Key file already exists at $KEY_FILE — skipping passphrase setup" | |
| warn "(Delete it and re-run to change your passphrase)" | |
| else | |
| echo "" | |
| echo " Choose a passphrase. It will be stored in $KEY_FILE" | |
| echo " and never asked again. If you lose it, re-run this script" | |
| echo " with the same passphrase to recover decryption ability." | |
| echo "" | |
| printf " Enter passphrase: " | |
| read -rs PASS; echo | |
| [[ -n "$PASS" ]] || die "Passphrase cannot be empty" | |
| printf '%s' "$PASS" > "$KEY_FILE" | |
| chmod 600 "$KEY_FILE" | |
| ok "Passphrase saved to $KEY_FILE (chmod 600)" | |
| fi | |
| # ── Deploy scripts to ~/.localcrypt/ ───────────────────────────────────────── | |
| SCRIPT_DIR="$HOME/.localcrypt" | |
| mkdir -p "$SCRIPT_DIR" | |
| # encrypt.sh | |
| # ---------- | |
| # Reads selected text from stdin (piped by macOS NSServices mechanism). | |
| # Encrypts with AES-256-CBC using the key file. | |
| # Puts ciphertext in clipboard — original text is cleared by the service. | |
| # Falls back to Cmd+C clipboard capture for apps that don't support NSServices. | |
| cat > "$SCRIPT_DIR/encrypt.sh" << 'ENCRYPT_SCRIPT' | |
| #!/bin/bash | |
| set -uo pipefail | |
| KEY_FILE="$HOME/.localcrypt_key" | |
| # macOS NSServices pipes the selected text to stdin (inputMethod=0 in workflow) | |
| INPUT=$(cat) | |
| if [[ -z "$INPUT" ]]; then | |
| # Fallback: simulate Cmd+C for apps that don't pipe via NSServices stdin | |
| osascript -e 'tell application "System Events" to tell (first application process whose frontmost is true) to keystroke "c" using command down' 2>/dev/null | |
| sleep 0.4 | |
| INPUT=$(pbpaste) | |
| fi | |
| if [[ -z "$INPUT" ]]; then | |
| osascript -e 'display notification "No text selected" with title "Encrypt"' 2>/dev/null | |
| exit 0 | |
| fi | |
| # Encrypt: AES-256-CBC, PBKDF2-SHA256, 100k iterations, base64 output | |
| # tr -d '\n' collapses multi-line base64 to a single line (avoids rich-text | |
| # editor issues when the ciphertext is pasted into tools like Productive) | |
| ENCRYPTED="" | |
| if ! ENCRYPTED=$(printf '%s' "$INPUT" \ | |
| | openssl enc -aes-256-cbc -pbkdf2 -iter 100000 -a \ | |
| -pass file:"$KEY_FILE" 2>/dev/null \ | |
| | tr -d '\n'); then | |
| osascript -e 'display notification "Encryption failed" with title "Encrypt"' 2>/dev/null | |
| exit 1 | |
| fi | |
| # Put ciphertext in clipboard — user pastes it with Cmd+V | |
| printf '%s' "$ENCRYPTED" | pbcopy | |
| osascript -e 'display notification "Encrypted — Cmd+V to paste" with title "Encrypt"' 2>/dev/null | |
| # No stdout: NSServices replaces the selected text with empty (original disappears), | |
| # which acts as visual confirmation that encryption happened | |
| ENCRYPT_SCRIPT | |
| # decrypt.sh | |
| # ---------- | |
| # Reads selected ciphertext from stdin. | |
| # Decrypts and outputs plaintext to stdout — NSServices replaces the | |
| # selected ciphertext with the plaintext in-place (no Cmd+V needed). | |
| cat > "$SCRIPT_DIR/decrypt.sh" << 'DECRYPT_SCRIPT' | |
| #!/bin/bash | |
| set -uo pipefail | |
| KEY_FILE="$HOME/.localcrypt_key" | |
| # macOS NSServices pipes selected text to stdin (inputMethod=0 in workflow). | |
| # tr -d '[:space:]' strips any trailing newline/whitespace added by the app. | |
| INPUT=$(cat | tr -d '[:space:]') | |
| if [[ -z "$INPUT" ]]; then | |
| # Fallback: simulate Cmd+C for apps that don't pipe via NSServices stdin | |
| osascript -e 'tell application "System Events" to tell (first application process whose frontmost is true) to keystroke "c" using command down' 2>/dev/null | |
| sleep 0.4 | |
| INPUT=$(pbpaste | tr -d '[:space:]') | |
| fi | |
| if [[ -z "$INPUT" ]]; then | |
| osascript -e 'display notification "No text selected" with title "Decrypt"' 2>/dev/null | |
| exit 0 | |
| fi | |
| # Decrypt: must match encrypt.sh parameters exactly | |
| # printf '%s\n' adds a trailing newline — openssl's base64 decoder requires it | |
| DECRYPTED="" | |
| if ! DECRYPTED=$(printf '%s\n' "$INPUT" \ | |
| | openssl enc -d -aes-256-cbc -pbkdf2 -iter 100000 -a \ | |
| -pass file:"$KEY_FILE" 2>/dev/null); then | |
| osascript -e 'display notification "Decryption failed — wrong key or corrupted text" with title "Decrypt"' 2>/dev/null | |
| exit 1 | |
| fi | |
| # Also put plaintext in clipboard as a convenience backup | |
| printf '%s' "$DECRYPTED" | pbcopy | |
| osascript -e 'display notification "Decrypted" with title "Decrypt"' 2>/dev/null | |
| # stdout: NSServices replaces the selected ciphertext with this output, | |
| # so the plaintext appears in-place without needing Cmd+V | |
| printf '%s' "$DECRYPTED" | |
| DECRYPT_SCRIPT | |
| chmod +x "$SCRIPT_DIR/encrypt.sh" "$SCRIPT_DIR/decrypt.sh" | |
| ok "Scripts installed to $SCRIPT_DIR" | |
| # ── Deploy Automator Quick Action workflows ─────────────────────────────────── | |
| # These are macOS Services (Quick Actions) that appear in the right-click menu | |
| # and can be assigned keyboard shortcuts. Each workflow runs a single | |
| # "Run Shell Script" action that calls our script above. | |
| # | |
| # Key workflow settings: | |
| # workflowTypeIdentifier = com.apple.Automator.servicesMenu → it's a Service | |
| # serviceInputTypeIdentifier = com.apple.Automator.text → text input | |
| # inputMethod = 0 → pipe selected text to script stdin (NOT as arguments) | |
| # NSReturnTypes = NSStringPboardType → stdout replaces the selected text | |
| SERVICES_DIR="$HOME/Library/Services" | |
| mkdir -p "$SERVICES_DIR" | |
| # Remove old versions if present | |
| rm -rf "$SERVICES_DIR/Encrypt.workflow" \ | |
| "$SERVICES_DIR/Decrypt.workflow" \ | |
| "$SERVICES_DIR/EncryptForAIR.workflow" \ | |
| "$SERVICES_DIR/DecryptForAIR.workflow" | |
| write_workflow() { | |
| local name="$1" # "Encrypt" or "Decrypt" | |
| local script="$2" # encrypt.sh or decrypt.sh | |
| local uuid_prefix="$3" | |
| local dir="$SERVICES_DIR/${name}.workflow/Contents" | |
| mkdir -p "$dir" | |
| # document.wflow — the Automator workflow definition | |
| cat > "$dir/document.wflow" << WFLOW | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| <plist version="1.0"> | |
| <dict> | |
| <key>AMApplicationBuild</key> | |
| <string>521.1</string> | |
| <key>AMApplicationVersion</key> | |
| <string>2.10</string> | |
| <key>AMDocumentVersion</key> | |
| <string>2</string> | |
| <key>actions</key> | |
| <array> | |
| <dict> | |
| <key>action</key> | |
| <dict> | |
| <key>AMAccepts</key> | |
| <dict> | |
| <key>Container</key> | |
| <string>List</string> | |
| <key>Optional</key> | |
| <true/> | |
| <key>Types</key> | |
| <array> | |
| <string>com.apple.cocoa.string</string> | |
| </array> | |
| </dict> | |
| <key>AMActionVersion</key> | |
| <string>2.0.3</string> | |
| <key>AMApplication</key> | |
| <array> | |
| <string>automator</string> | |
| </array> | |
| <key>AMParameterProperties</key> | |
| <dict> | |
| <key>COMMAND_STRING</key> | |
| <dict/> | |
| <key>CheckedForUserDefaultShell</key> | |
| <dict/> | |
| <key>inputMethod</key> | |
| <dict/> | |
| <key>shell</key> | |
| <dict/> | |
| <key>source</key> | |
| <dict/> | |
| </dict> | |
| <key>AMProvides</key> | |
| <dict> | |
| <key>Container</key> | |
| <string>List</string> | |
| <key>Types</key> | |
| <array> | |
| <string>com.apple.cocoa.string</string> | |
| </array> | |
| </dict> | |
| <key>ActionBundlePath</key> | |
| <string>/System/Library/Automator/Run Shell Script.action</string> | |
| <key>ActionName</key> | |
| <string>Run Shell Script</string> | |
| <key>ActionParameters</key> | |
| <dict> | |
| <key>COMMAND_STRING</key> | |
| <string>#!/bin/bash | |
| "\$HOME/.localcrypt/${script}"</string> | |
| <key>CheckedForUserDefaultShell</key> | |
| <true/> | |
| <key>inputMethod</key> | |
| <integer>0</integer> | |
| <key>shell</key> | |
| <string>/bin/bash</string> | |
| <key>source</key> | |
| <string></string> | |
| </dict> | |
| <key>BundleIdentifier</key> | |
| <string>com.apple.RunShellScript</string> | |
| <key>CFBundleVersion</key> | |
| <string>2.0.3</string> | |
| <key>CanShowSelectedItemsWhenRun</key> | |
| <false/> | |
| <key>CanShowWhenRun</key> | |
| <true/> | |
| <key>Category</key> | |
| <array> | |
| <string>AMCategoryUtilities</string> | |
| </array> | |
| <key>Class Name</key> | |
| <string>RunShellScriptAction</string> | |
| <key>InputUUID</key> | |
| <string>${uuid_prefix}0</string> | |
| <key>Keywords</key> | |
| <array> | |
| <string>Shell</string> | |
| <string>Script</string> | |
| <string>Command</string> | |
| <string>Run</string> | |
| <string>Unix</string> | |
| </array> | |
| <key>OutputUUID</key> | |
| <string>${uuid_prefix}1</string> | |
| <key>UUID</key> | |
| <string>${uuid_prefix}2</string> | |
| <key>UnlocalizedApplications</key> | |
| <array> | |
| <string>Automator</string> | |
| </array> | |
| <key>arguments</key> | |
| <dict> | |
| <key>0</key> | |
| <dict> | |
| <key>default value</key> | |
| <integer>0</integer> | |
| <key>name</key> | |
| <string>inputMethod</string> | |
| <key>required</key> | |
| <string>0</string> | |
| <key>type</key> | |
| <string>0</string> | |
| <key>uuid</key> | |
| <string>0</string> | |
| </dict> | |
| <key>1</key> | |
| <dict> | |
| <key>default value</key> | |
| <string></string> | |
| <key>name</key> | |
| <string>source</string> | |
| <key>required</key> | |
| <string>0</string> | |
| <key>type</key> | |
| <string>0</string> | |
| <key>uuid</key> | |
| <string>1</string> | |
| </dict> | |
| </dict> | |
| <key>isViewVisible</key> | |
| <true/> | |
| <key>location</key> | |
| <string>309.000000:253.000000</string> | |
| <key>nibPath</key> | |
| <string>/System/Library/Automator/Run Shell Script.action/Contents/Resources/Base.lproj/main.nib</string> | |
| </dict> | |
| <key>isViewVisible</key> | |
| <true/> | |
| </dict> | |
| </array> | |
| <key>connectors</key> | |
| <dict/> | |
| <key>workflowMetaData</key> | |
| <dict> | |
| <key>serviceInputTypeIdentifier</key> | |
| <string>com.apple.Automator.text</string> | |
| <key>serviceOutputTypeIdentifier</key> | |
| <string>com.apple.Automator.text</string> | |
| <key>serviceProcessesInput</key> | |
| <integer>0</integer> | |
| <key>workflowTypeIdentifier</key> | |
| <string>com.apple.Automator.servicesMenu</string> | |
| </dict> | |
| </dict> | |
| </plist> | |
| WFLOW | |
| # Info.plist — registers this workflow as a macOS Service | |
| cat > "$dir/Info.plist" << INFOPLIST | |
| <?xml version="1.0" encoding="UTF-8"?> | |
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | |
| <plist version="1.0"> | |
| <dict> | |
| <key>NSServices</key> | |
| <array> | |
| <dict> | |
| <key>NSMenuItem</key> | |
| <dict> | |
| <key>default</key> | |
| <string>${name}</string> | |
| </dict> | |
| <key>NSMessage</key> | |
| <string>runWorkflowAsService</string> | |
| <key>NSPortName</key> | |
| <string>${name}</string> | |
| <key>NSRequiredContext</key> | |
| <dict/> | |
| <key>NSSendTypes</key> | |
| <array> | |
| <string>NSStringPboardType</string> | |
| </array> | |
| <key>NSReturnTypes</key> | |
| <array> | |
| <string>NSStringPboardType</string> | |
| </array> | |
| </dict> | |
| </array> | |
| </dict> | |
| </plist> | |
| INFOPLIST | |
| } | |
| write_workflow "Encrypt" "encrypt.sh" "A1B2C3D4-E5F6-7890-ABCD-EF123456789" | |
| write_workflow "Decrypt" "decrypt.sh" "B1B2C3D4-E5F6-7890-ABCD-EF123456789" | |
| ok "Workflows installed to $SERVICES_DIR" | |
| # ── Register keyboard shortcuts ─────────────────────────────────────────────── | |
| # pbs (pasteboard server) manages Services registration. | |
| # Modifier notation: @ = Cmd, ^ = Ctrl, $ = Shift | |
| # So "@^$e" = Cmd+Ctrl+Shift+E | |
| defaults write pbs NSServicesStatus \ | |
| '{"(null) - Encrypt - runWorkflowAsService" = {key_equivalent = "@^\$e"; }; | |
| "(null) - Decrypt - runWorkflowAsService" = {key_equivalent = "@^\$d"; };}' | |
| # Flush the Services cache so macOS picks up the new workflows immediately | |
| /System/Library/CoreServices/pbs -flush | |
| killall WorkflowServiceRunner 2>/dev/null || true | |
| ok "Shortcuts registered and Services cache flushed" | |
| # ── Done ────────────────────────────────────────────────────────────────────── | |
| echo "" | |
| echo " ════════════════════════════════════════════" | |
| echo " Installation complete!" | |
| echo "" | |
| echo " Encrypt: Cmd+Ctrl+Shift+E" | |
| echo " Decrypt: Cmd+Ctrl+Shift+D" | |
| echo "" | |
| echo " HOW TO USE" | |
| echo " 1. Select any text" | |
| echo " 2. Press Cmd+Ctrl+Shift+E — text disappears" | |
| echo " 3. Press Cmd+V to paste the ciphertext" | |
| echo " 4. To decrypt: select ciphertext, press Cmd+Ctrl+Shift+D" | |
| echo " — plaintext replaces it in-place" | |
| echo "" | |
| echo " Works best in Chrome and Chrome-based browsers." | |
| echo " Also available via right-click → Services menu." | |
| echo "" | |
| echo " If shortcuts don't fire after install:" | |
| echo " System Settings → Keyboard → Keyboard Shortcuts" | |
| echo " → Services → Text → enable Encrypt + Decrypt" | |
| echo " ════════════════════════════════════════════" | |
| echo "" | |
| # Offer to open System Settings for manual shortcut verification | |
| read -rp " Open System Settings → Keyboard Shortcuts now? [y/N] " ans | |
| if [[ "${ans,,}" == "y" ]]; then | |
| open "x-apple.systempreferences:com.apple.preference.keyboard?Shortcuts" 2>/dev/null || true | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment