pbj uses PIN-protected split-key encryption for secure key recovery. This document explains the security architecture, threat model, and design decisions.
When recovering encryption keys via GitHub Actions workflows, the key must be transmitted through workflow logs. Traditional approaches expose the raw key in logs, creating a security vulnerability.
pbj splits the key recovery mechanism into two components:
- User's PIN (4 digits) - Stored in user's memory
- PIN Padding (28 random bytes) - Hardcoded in source code
Neither component alone can decrypt the recovery key. Both are required.
User's PIN (4 digits) → "1234"
PIN bytes → [0x31, 0x32, 0x33, 0x34] (4 bytes)
PIN_PADDING (hardcoded) → <28 random bytes>
Combined Key → PIN bytes + PIN_PADDING = 32 bytes
AES-256-GCM Encryption Key → 32-byte key for encrypting PBJ_KEY
PBJ_KEY (32 bytes)
↓
Encrypt with (PIN + PIN_PADDING)
↓
IV (12) + Auth Tag (16) + Ciphertext (32) = 60 bytes
↓
Base64 encode → ~80 character string
↓
Log to GitHub Actions workflow
GitHub Actions Workflow Log
↓
Extract encrypted blob (base64)
↓
User enters PIN on local device
↓
Derive key: PIN + PIN_PADDING
↓
Decrypt with AES-256-GCM
↓
Verify auth tag (prevents tampering)
↓
Original PBJ_KEY recovered
| Threat | Mitigation |
|---|---|
| Attacker has workflow logs | Encrypted blob useless without PIN + padding |
| Attacker has source code | PIN_PADDING known, but PIN is not |
| Attacker has GitHub Secrets access | PIN stored, but padding not in secrets |
| Attacker has logs + source | Still needs user's PIN (unknown) |
| Brute force PIN | 10,000 combinations × padding entropy = infeasible |
| Tampered encrypted blob | GCM auth tag verification fails |
| Replay attack | Each encryption uses random IV |
✅ Confidentiality: Encrypted blob protects key ✅ Authenticity: GCM auth tag prevents tampering ✅ Forward Security: Random IV prevents pattern recognition ✅ Split Knowledge: Requires both PIN (user) and padding (code) ✅ No Single Point of Failure: Neither component alone sufficient
❌ Physical access to unlocked device - Out of scope ❌ Keylogger on user's machine - Out of scope ❌ Compromised GitHub account - User responsible for account security ❌ Social engineering for PIN - User operational security
- 4 digits = 10^4 = 10,000 combinations
- Entropy: log₂(10,000) ≈ 13.3 bits
- 28 random bytes = 28 × 8 = 224 bits
- Entropy: 224 bits
- Total: 13.3 + 224 = 237.3 bits
- AES-256 security: 256 bits
- Effective security: min(237.3, 256) = 237 bits (very strong)
Even if attacker has:
- Encrypted blob from logs ✓
- PIN_PADDING from source code ✓
They still need to brute force:
- 10,000 PIN combinations
- Each attempt requires AES-256-GCM decryption
- Auth tag verification (expensive)
Time to brute force (assuming 1 million attempts/second):
- 10,000 PINs ÷ 1,000,000 attempts/sec = 0.01 seconds
However, the 3-attempt limit in pbj recover makes online brute force impractical:
- User must manually trigger workflow each time
- Workflow takes 30-60 seconds
- GitHub rate limits apply
- Workflow logs are deletable
❌ Anyone with log access can steal key ❌ No protection if workflow logs leaked ❌ Must delete workflow run immediately
❌ 4 digits = only 10,000 combinations ❌ Trivially brute-forceable offline ❌ Not secure enough for AES-256
❌ Padding in source code = everyone has it ❌ No user-specific secret ❌ Any attacker with logs can decrypt
✅ Requires both user knowledge and source code ✅ 237-bit effective entropy ✅ Cannot brute force offline ✅ Simple for users (4-digit PIN) ✅ Strong cryptographic security
| Location | Format | Security |
|---|---|---|
| User's Memory | 4 digits | Best security (nowhere else) |
| GitHub Secrets (PBJ_PIN) | Plaintext | Write-only, cannot read via API |
| Workflow Logs | ❌ Never logged | GitHub masks secret values |
| Source Code | ❌ Never stored | User-specific |
GitHub Secrets are:
- Write-only: Cannot be read via API
- Masked in logs: Appear as
***if printed - Encrypted at rest: GitHub manages encryption
- Scoped to repository: Only accessible by that repo's workflows
Even if someone accesses GitHub Secrets, they get:
- PBJ_KEY (encrypted storage key)
- PBJ_PIN (user's PIN)
- But NOT PIN_PADDING (in source code, not secrets)
To decrypt workflow logs, attacker needs:
- Encrypted blob (from logs)
- PIN (from secrets)
- PIN_PADDING (from source code)
All three components required.
Why GCM mode?
- Authenticated encryption (integrity + confidentiality)
- Detects tampering via auth tag
- Industry standard for secure encryption
- Built into Ruby's OpenSSL library
def derive_pin_key(pin)
pin_bytes = pin.bytes # "1234" → [49, 50, 51, 52]
key = pin_bytes.pack('C*') + PIN_PADDING # 4 + 28 = 32 bytes
key
endWhy simple concatenation?
- PIN space is small (10,000 values)
- Full 28-byte random padding provides entropy
- No need for PBKDF2/scrypt (not protecting against dictionary attacks)
- Goal is split-key security, not password-based encryption
[ IV (12 bytes) | Auth Tag (16 bytes) | Ciphertext (32 bytes) ]
↓ ↓ ↓
Random IV GCM auth tag Encrypted PBJ_KEY
Base64 encoded: 60 bytes → 80 characters
Each encryption generates a new random IV:
- Ensures different ciphertexts for same plaintext
- Prevents pattern recognition
- Standard cryptographic best practice
- Remember your PIN - Cannot be recovered (by design)
- Use unique PIN - Don't reuse PINs from other services
- Delete workflow runs - After successful recovery
- Protect GitHub account - Enable 2FA, use strong password
- Keep devices secure - .pbj-key file has 0600 permissions
There is NO PIN recovery mechanism.
This is intentional:
- PIN recovery = backdoor = security weakness
- If you forget PIN, you must manually copy .pbj-key file
- Encourages users to remember their PIN
- Forces users to understand the security model
If you forget your PIN but have access to a device with the key:
# On device with working key
pbj set-pin
# Set new PIN
# Old encrypted logs become unreadable (expected)| Tool | Key Storage | Recovery Method | Security Level |
|---|---|---|---|
| 1Password | Master password + secret key | Recovery key | ⭐⭐⭐⭐⭐ |
| LastPass | Master password | Master password | ⭐⭐⭐⭐ |
| pbcopy | None (local only) | N/A | ⭐ |
| Pastebin | None (public!) | URL | ❌ |
| pbj (old) | GitHub Secrets | Raw key in logs | ⭐⭐⭐ |
| pbj (PIN) | GitHub Secrets | PIN-encrypted logs | ⭐⭐⭐⭐ |
- Encryption uses AES-256-GCM (authenticated)
- Keys are 32 bytes (256 bits)
- IVs are random (not reused)
- Auth tags verified on decryption
- PIN never stored in logs
- PIN never stored in source code
- Raw key never exposed in workflow logs
- Encrypted blobs differ each time (random IV)
- Wrong PIN causes decryption failure
- 3-attempt limit prevents online brute force
- Backward compatibility maintains old recovery
- Clear migration path documented
- User warnings about PIN loss
- GitHub Secrets are write-only
- PIN_PADDING is permanent (documented)
- Longer PINs: Optional 6-8 digit PINs (more entropy)
- Passphrase Option: Full passphrase instead of 4-digit PIN
- Hardware Token: YubiKey/FIDO2 for key derivation
- Key Rotation: Automatic key rotation with PIN re-entry
- Audit Logging: Track recovery attempts in repo
❌ PIN recovery mechanism - Intentionally not implemented ❌ Biometric unlock - Device-specific, not portable ❌ Zero-knowledge proofs - Overkill for this use case ❌ Homomorphic encryption - Unnecessary complexity
If you discover a security vulnerability in pbj:
- DO NOT open a public GitHub issue
- DO email: security@[maintainer-domain]
- Include: Detailed description, proof of concept, impact assessment
- Wait: For acknowledgment before public disclosure
We follow coordinated disclosure practices.
Last Updated: 2025-10-25 Security Model Version: 1.0 Cryptographic Review: Pending