From a2b397dd4616dac7a6c5db61e0862362e61c6f3f Mon Sep 17 00:00:00 2001 From: neil_pack Date: Mon, 17 Aug 2026 10:10:42 -0600 Subject: [PATCH] Add optional AES-256-GCM session token encryption with security documentation and environment variable support for production deployments --- .gitignore | 4 + README.md | 166 +++- RERECORD_VCR_GUIDE.md | 2 +- SECURITY.md | 751 +++++++++++++++++ examples/.env.example | 104 +++ examples/_includes.php | 80 +- src/FamilySearch.php | 759 +++++++++++++++++- .../Integration/EncryptedSessionFlowTest.php | 490 +++++++++++ tests/Unit/SessionEncryptionTest.php | 537 +++++++++++++ 9 files changed, 2873 insertions(+), 20 deletions(-) create mode 100644 SECURITY.md create mode 100644 examples/.env.example create mode 100644 tests/Integration/EncryptedSessionFlowTest.php create mode 100644 tests/Unit/SessionEncryptionTest.php diff --git a/.gitignore b/.gitignore index 0a90921..18d7a06 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ coverage.xml .phpunit.result.cache tests/Integration/SandboxCredentials.php .idea/ + +# Environment files +.env +examples/.env diff --git a/README.md b/README.md index 0cfa5b0..ea3a199 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,8 @@ ![Tests](https://github.com/FamilySearch/fs-php-lite/workflows/Tests/badge.svg?branch=master) [![PHP Version](https://img.shields.io/badge/php-8.0%20%7C%208.1%20%7C%208.2%20%7C%208.3-blue.svg)](https://github.com/FamilySearch/fs-php-lite) +> **⚠️ Security Notice:** Access tokens are stored in plaintext by default. Enable encryption in production. See [Security Considerations](#security-considerations). + Lite PHP SDK for the [FamilySearch API](https://familysearch.org/developers/). __Warning__: this SDK requires hard-coding the API endpoint URLs. That is @@ -23,13 +25,17 @@ include_once('FamilySearch.php'); // Create the SDK instance $fs = new FamilySearch([ 'environment' => 'production', - 'appKey' => 'ahfud9Adjfia', + 'appKey' => $_ENV['FS_APP_KEY'], // NEVER hardcode credentials - use environment variables 'redirectUri' => 'https://example.com/fs-redirect', // Tell it to automatically save and load the access token from $_SESSION. 'sessions' => true, // This defaults to true 'sessionVariable' => 'FS_ACCESS_TOKEN', + // RECOMMENDED: Enable AES-256-GCM encryption for session tokens in production + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $_ENV['FS_SESSION_ENCRYPTION_KEY'], // NEVER hardcode the key! + // Necessary for when the developer wants to store the accessToken somewhere // besides $_SESSION 'accessToken' => '', @@ -118,6 +124,164 @@ $response = $fs->request('/platform/tree/persons/PPPP-PPP', [ ]); ``` +## Security Considerations + +### Session Token Encryption + +**⚠️ Important:** By default, OAuth access tokens are stored in PHP `$_SESSION` in **plaintext**. This means tokens can be read by anyone with filesystem access to your server's session directory (typically `/var/lib/php/sessions`). + +**For production deployments**, enable optional **AES-256-GCM encryption** to protect tokens at rest: + +```php +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'environment' => 'production', + + // Enable session encryption (RECOMMENDED for production) + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $_ENV['FS_SESSION_ENCRYPTION_KEY'] +]); +``` + +#### Generating an Encryption Key + +Generate a secure 32-byte encryption key: + +```bash +# Generate a base64-encoded key (recommended) +php -r "echo base64_encode(random_bytes(32));" +# Output: WdaFfj4iL3Epz2o9phaBbh7FyA5fJs3lCcr6YB4QQxo= + +# Or generate a hex-encoded key +php -r "echo bin2hex(random_bytes(32));" +# Output: 4c0bd859f72d55003baa72e76fea385e599c9562b1b75a1fec0831b19f04118a +``` + +#### Key Storage Best Practices + +**✅ DO:** +- Store encryption keys in **environment variables** +- Use a secrets manager (AWS Secrets Manager, HashiCorp Vault, Azure Key Vault) +- Use different keys for different environments (dev, staging, production) +- Rotate keys periodically (every 90 days recommended) + +**❌ DO NOT:** +- Hardcode keys in source code +- Commit keys to version control +- Reuse the same key across environments +- Use weak or predictable keys + +**Example with environment variable:** + +```bash +# Set environment variable +export FS_SESSION_ENCRYPTION_KEY="WdaFfj4iL3Epz2o9phaBbh7FyA5fJs3lCcr6YB4QQxo=" + +# Or in .env file (excluded from Git) +echo "FS_SESSION_ENCRYPTION_KEY=WdaFfj4iL3Epz2o9phaBbh7FyA5fJs3lCcr6YB4QQxo=" >> .env +``` + +#### What Encryption Protects + +Session token encryption protects against: + +- ✅ **Filesystem access** - Attackers who gain read access to session files +- ✅ **Backup exposure** - Tokens remain protected in backups +- ✅ **Disk forensics** - Deleted session files cannot reveal plaintext tokens +- ✅ **Accidental logging** - Encrypted values logged instead of plaintext +- ✅ **Shared hosting risks** - Other tenants cannot read your tokens + +#### What Encryption Does NOT Protect Against + +Encryption is **not a silver bullet**. It does **not** protect against: + +- ❌ **Active server compromise** - Attackers with code execution can access keys +- ❌ **Memory dumps** - Tokens are plaintext in memory during request processing +- ❌ **XSS attacks** - Client-side attacks bypass server-side encryption +- ❌ **Session hijacking** - Valid session IDs grant access regardless of encryption +- ❌ **Network interception** - HTTPS is required separately + +**Bottom Line:** Encryption protects data **at rest**. You also need HTTPS, secure session management, XSS protection, and proper server hardening. + +### Enabling Encryption on Existing Deployments + +Enabling encryption on an existing application is **seamless and backward-compatible**. No downtime or manual migration required. + +#### Step 1: Generate Encryption Key + +```bash +php -r "echo base64_encode(random_bytes(32));" +# Copy the output: WdaFfj4iL3Epz2o9phaBbh7FyA5fJs3lCcr6YB4QQxo= +``` + +#### Step 2: Store in Environment Variable + +```bash +# Development/staging +export FS_SESSION_ENCRYPTION_KEY="your-generated-key-here" + +# Production (use your deployment platform's secrets management) +# Heroku: heroku config:set FS_SESSION_ENCRYPTION_KEY="your-key" +# AWS: Store in Parameter Store or Secrets Manager +# Docker: Use Docker secrets +``` + +#### Step 3: Update SDK Configuration + +```php +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'sessionEncryption' => true, // Add this line + 'sessionEncryptionKey' => $_ENV['FS_SESSION_ENCRYPTION_KEY'] // Add this line +]); +``` + +#### Step 4: Deploy Changes + +Deploy your updated application. **No manual intervention needed.** + +#### Step 5: Automatic Migration + +The migration happens automatically: + +1. **Existing sessions** with plaintext tokens continue to work (backward compatible) +2. **New OAuth flows** store tokens encrypted +3. When users re-authenticate, their tokens are encrypted automatically +4. After natural session expiration (~24 hours), all tokens are encrypted + +**No forced logout. No disruption. No manual migration scripts required.** + +#### Verification + +Verify encryption is working: + +```bash +# Check session files (tokens should look encrypted) +sudo cat /var/lib/php/sessions/sess_* | grep FS_ACCESS_TOKEN + +# Encrypted format looks like: s:120:"base64data:base64data:base64data"; +# Plaintext format looks like: s:45:"actual-token-value-here"; +``` + +### Additional Security Recommendations + +1. **Enable HTTPS** - Always use HTTPS in production +2. **Secure session cookies** - Set `session.cookie_secure = 1` in `php.ini` +3. **HTTPOnly cookies** - Set `session.cookie_httponly = 1` to prevent XSS +4. **SameSite cookies** - Set `session.cookie_samesite = "Strict"` for CSRF protection +5. **Session directory permissions** - Ensure session files are not world-readable: + ```bash + sudo chmod 700 /var/lib/php/sessions + ``` +6. **Regular key rotation** - Rotate encryption keys every 90 days + +For comprehensive security guidance, see **[SECURITY.md](SECURITY.md)** which includes: +- Detailed threat model +- Server configuration best practices +- Key rotation procedures +- Production deployment checklist +- Incident response guidelines + ## Serialization with gedcomx-php When the `objects` configuration option is set to true, the diff --git a/RERECORD_VCR_GUIDE.md b/RERECORD_VCR_GUIDE.md index 668d6d0..9311f9c 100644 --- a/RERECORD_VCR_GUIDE.md +++ b/RERECORD_VCR_GUIDE.md @@ -199,7 +199,7 @@ jq -r '.[0].request.headers["User-Agent"]' tests/fixtures/testAuthenticate.json **Expected:** ``` -FS-PHP-Lite/1.2.0 curl/8.x PHP/8.x +FS-PHP-Lite/1.3.0 curl/8.x PHP/8.x ``` **NOT (old):** diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..a9ea4cb --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,751 @@ +# Security Policy + +## Security Overview + +### What the SDK Provides + +The FamilySearch PHP Lite SDK provides: + +- **Optional AES-256-GCM encryption** for OAuth access tokens stored in PHP sessions +- **Authenticated encryption** with tamper detection (prevents ciphertext modification) +- **Automatic key normalization** supporting multiple key formats (raw, base64, hex, passphrase) +- **Backward compatibility** for seamless migration from plaintext to encrypted storage +- **Fail-secure behavior** (encryption failures never fall back to plaintext storage) + +### What You Are Responsible For + +As a developer using this SDK, you are responsible for: + +- **Enabling encryption** in production environments +- **Generating and managing** secure encryption keys +- **Configuring PHP** session settings securely +- **Setting proper file permissions** on session storage directories +- **Enforcing HTTPS** for all API communications +- **Implementing secure session management** practices + +--- + +## Session Token Storage + +### Default Behavior: Plaintext Storage + +**⚠️ Warning:** By default, OAuth access tokens are stored in plaintext in PHP session files. + +```php +// Default configuration (NOT SECURE for production) +$fs = new FamilySearch([ + 'appKey' => 'your-app-key', + 'sessionEncryption' => false // Default: tokens stored in plaintext +]); +``` + +**Risk:** If an attacker gains read access to your server's filesystem, they can read session files and extract access tokens. This could happen through: +- Misconfigured file permissions +- Backup file exposure +- Server compromise +- Shared hosting environment vulnerabilities +- Container/VM snapshot leaks + +### Secure Configuration: Encrypted Storage + +**✅ Recommended:** Enable AES-256-GCM encryption for production: + +```php +$fs = new FamilySearch([ + 'appKey' => $_ENV['FS_APP_KEY'], + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $_ENV['FS_SESSION_ENCRYPTION_KEY'] +]); +``` + +### What Encryption Protects Against + +Encryption provides **defense-in-depth** against: + +✅ **Passive filesystem access** (attacker reads session files from disk) +✅ **Backup exposure** (encrypted session files in backups remain protected) +✅ **Forensic analysis** (disk forensics cannot recover plaintext tokens) +✅ **Accidental logging** (encrypted values logged instead of plaintext tokens) +✅ **Container/VM snapshots** (session data remains encrypted in snapshots) +✅ **Shared hosting risks** (other tenants cannot read your tokens) + +### What Encryption Does NOT Protect Against + +Encryption is **not a silver bullet**. It does NOT protect against: + +❌ **Memory dumps** (tokens are plaintext in PHP process memory during execution) +❌ **Active server compromise** (attacker with code execution can access encryption keys) +❌ **XSS attacks** (client-side JavaScript attacks bypass server-side encryption) +❌ **Session hijacking** (valid session IDs grant access regardless of encryption) +❌ **Stolen encryption keys** (attacker with key can decrypt all tokens) +❌ **Network interception** (HTTPS is required separately for transport security) + +**Bottom Line:** Encryption protects data **at rest** on disk. You still need proper access controls, secure coding practices, HTTPS, and secure session management. + +--- + +## Encryption Best Practices + +### 1. Generate Secure Encryption Keys + +**✅ Correct: Use cryptographically secure random bytes** + +```bash +# Generate a secure 32-byte key and encode as base64 +php -r "echo base64_encode(random_bytes(32));" +# Output: WdaFfj4iL3Epz2o9phaBbh7FyA5fJs3lCcr6YB4QQxo= + +# Alternative: Using OpenSSL +php -r "echo base64_encode(openssl_random_pseudo_bytes(32));" + +# Or generate hex format +php -r "echo bin2hex(random_bytes(32));" +# Output: 4c0bd859f72d55003baa72e76fea385e599c9562b1b75a1fec0831b19f04118a +``` + +**❌ Wrong: Weak or predictable keys** + +```php +// DO NOT DO THIS - Weak keys +'sessionEncryptionKey' => 'mysecretkey' // Too short, predictable +'sessionEncryptionKey' => 'password123' // Dictionary word +'sessionEncryptionKey' => md5('my-app-name') // Predictable +'sessionEncryptionKey' => date('Y-m-d') // Guessable +``` + +### 2. Store Keys in Environment Variables + +**✅ Correct: Environment variables** + +```php +// Load key from environment (12-factor app pattern) +$fs = new FamilySearch([ + 'sessionEncryptionKey' => $_ENV['FS_SESSION_ENCRYPTION_KEY'] +]); +``` + +```bash +# Set environment variable in production +export FS_SESSION_ENCRYPTION_KEY="WdaFfj4iL3Epz2o9phaBbh7FyA5fJs3lCcr6YB4QQxo=" + +# Or use .env file (excluded from version control) +echo "FS_SESSION_ENCRYPTION_KEY=WdaFfj4iL3Epz2o9phaBbh7FyA5fJs3lCcr6YB4QQxo=" >> .env +``` + +**❌ Wrong: Hardcoded in source code** + +```php +// DO NOT DO THIS - Key in source code +$fs = new FamilySearch([ + 'sessionEncryptionKey' => 'WdaFfj4iL3Epz2o9phaBbh7FyA5fJs3lCcr6YB4QQxo=' // NEVER COMMIT THIS +]); +``` + +### 3. Use Secure Key Storage Solutions + +For production environments, use dedicated secrets management: + +**Cloud Providers:** +- **AWS:** AWS Secrets Manager or Parameter Store +- **Azure:** Azure Key Vault +- **GCP:** Google Secret Manager +- **Heroku:** Config Vars +- **Docker:** Docker Secrets + +**Self-Hosted:** +- **HashiCorp Vault** +- **Kubernetes Secrets** +- **Ansible Vault** + +**Example with AWS Secrets Manager:** + +```php +// Retrieve key from AWS Secrets Manager +$client = new SecretsManagerClient(['region' => 'us-east-1']); +$result = $client->getSecretValue(['SecretId' => 'fs-session-encryption-key']); +$key = json_decode($result['SecretString'], true)['key']; + +$fs = new FamilySearch([ + 'sessionEncryptionKey' => $key +]); +``` + +### 4. Key Rotation Strategy + +Rotate encryption keys periodically (every 90 days recommended): + +**Step 1: Generate new key** +```bash +php -r "echo base64_encode(random_bytes(32));" +``` + +**Step 2: Deploy new key** (keep old key available temporarily) +```bash +# Set new key +export FS_SESSION_ENCRYPTION_KEY_NEW="" +``` + +**Step 3: Migrate sessions** (users re-authenticate naturally over time) +- Old sessions decrypt with old key +- New sessions encrypt with new key +- After migration period (7-30 days), remove old key + +**Step 4: Update application** +```php +// Try new key first, fallback to old key during migration +$keys = [ + $_ENV['FS_SESSION_ENCRYPTION_KEY_NEW'], // Primary key + $_ENV['FS_SESSION_ENCRYPTION_KEY_OLD'] // Fallback during migration +]; +``` + +### 5. Environment-Specific Keys + +**✅ Use different keys per environment:** + +```bash +# Development +FS_SESSION_ENCRYPTION_KEY="dev-key-here" + +# Staging +FS_SESSION_ENCRYPTION_KEY="staging-key-here" + +# Production +FS_SESSION_ENCRYPTION_KEY="production-key-here" +``` + +**❌ Never reuse keys across environments.** If a development key is compromised, it should not affect production. + +--- + +## Server Configuration + +### 1. PHP Session Directory Permissions + +**Verify your session directory permissions:** + +```bash +# Find your session directory +php -r "echo session_save_path();" + +# Check permissions +ls -ld /var/lib/php/sessions + +# Should show: drwx------ (700) - only owner can read/write/execute +``` + +**✅ Secure configuration:** + +```bash +# Set proper permissions (owner only) +sudo chmod 700 /var/lib/php/sessions +sudo chown www-data:www-data /var/lib/php/sessions # Use your web server user +``` + +**❌ Insecure configurations to avoid:** + +```bash +# DO NOT DO THIS +sudo chmod 777 /var/lib/php/sessions # World-readable! Anyone can read tokens! +sudo chmod 755 /var/lib/php/sessions # World-readable! +``` + +### 2. PHP Session Configuration + +**Edit `/etc/php/8.x/apache2/php.ini` (or `/etc/php/8.x/fpm/php.ini`):** + +```ini +; Session save path with proper permissions +session.save_path = "/var/lib/php/sessions" + +; Use strict mode - reject uninitialized session IDs +session.use_strict_mode = 1 + +; Cookies only (no URL session IDs) +session.use_cookies = 1 +session.use_only_cookies = 1 + +; HTTPS only in production (prevents interception) +session.cookie_secure = 1 + +; HTTP only (prevents JavaScript access - XSS mitigation) +session.cookie_httponly = 1 + +; SameSite protection (CSRF mitigation) +session.cookie_samesite = "Strict" + +; Prevent session fixation +session.use_trans_sid = 0 + +; Regenerate session ID after authentication +; (implement in your application code) + +; Strong session ID entropy +session.sid_length = 48 +session.sid_bits_per_character = 6 +``` + +**Apply configuration changes:** + +```bash +# Apache +sudo systemctl restart apache2 + +# Nginx + PHP-FPM +sudo systemctl restart php8.x-fpm +sudo systemctl restart nginx +``` + +### 3. Verify Session Security + +**Test script to verify session configuration:** + +```php + +``` + +### 4. Enforce HTTPS + +**Apache `.htaccess`:** + +```apache +# Force HTTPS +RewriteEngine On +RewriteCond %{HTTPS} off +RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] +``` + +**Nginx:** + +```nginx +# Force HTTPS +server { + listen 80; + server_name example.com; + return 301 https://$server_name$request_uri; +} + +server { + listen 443 ssl http2; + server_name example.com; + + ssl_certificate /etc/ssl/certs/example.com.crt; + ssl_certificate_key /etc/ssl/private/example.com.key; + + # Strong SSL configuration + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384'; + ssl_prefer_server_ciphers on; + + # ... rest of configuration +} +``` + +--- + +## Production Deployment Checklist + +Use this checklist before deploying to production: + +### Application Security +- [ ] **Encryption enabled** (`sessionEncryption: true`) +- [ ] **Encryption key generated** using `random_bytes(32)` +- [ ] **Encryption key stored** in environment variable (not hardcoded) +- [ ] **Different keys** per environment (dev, staging, production) +- [ ] **No credentials hardcoded** in application code +- [ ] **No credentials committed** to version control + +### Server Configuration +- [ ] **Session directory permissions** set to `700` (owner only) +- [ ] **Session directory owner** is web server user (e.g., `www-data`) +- [ ] **PHP session settings** configured securely (see above) +- [ ] **`session.cookie_secure = 1`** (HTTPS only) +- [ ] **`session.cookie_httponly = 1`** (no JavaScript access) +- [ ] **`session.cookie_samesite = "Strict"`** (CSRF protection) +- [ ] **HTTPS enforced** for entire application +- [ ] **Valid SSL certificate** installed and auto-renewing + +### Monitoring & Maintenance +- [ ] **Security headers** configured (CSP, X-Frame-Options, etc.) +- [ ] **Error logging** enabled but errors not displayed to users +- [ ] **Key rotation schedule** established (every 90 days) +- [ ] **Security updates** process for PHP and dependencies +- [ ] **Backup encryption keys** stored securely (encrypted backups) +- [ ] **Incident response plan** documented + +### Testing +- [ ] **Test encryption** works correctly in staging +- [ ] **Test session persistence** across requests +- [ ] **Test key rotation** procedure +- [ ] **Test HTTPS enforcement** (HTTP should redirect) +- [ ] **Verify session cookies** have secure flags set + +--- + +## Threat Model + +### Threat: Filesystem Access to Session Files + +**Risk Level:** HIGH + +**Attack Scenario:** +- Attacker gains read access to server filesystem +- Session files in `/var/lib/php/sessions` are readable +- Attacker extracts plaintext OAuth tokens from session files + +**Mitigation:** + +1. **Enable encryption** (primary defense): + ```php + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $_ENV['FS_SESSION_ENCRYPTION_KEY'] + ``` + +2. **Set proper file permissions** (defense-in-depth): + ```bash + chmod 700 /var/lib/php/sessions + ``` + +3. **Use separate storage** (advanced): + ```php + // Store sessions in Redis or Memcached + session.save_handler = redis + session.save_path = "tcp://127.0.0.1:6379" + ``` + +**Residual Risk:** LOW (after mitigations) + +--- + +### Threat: Session Hijacking + +**Risk Level:** HIGH + +**Attack Scenario:** +- Attacker intercepts session cookie (via XSS, network sniffing, or malware) +- Attacker uses stolen session ID to impersonate user +- Encryption doesn't prevent this (attacker has valid session ID) + +**Mitigation:** + +1. **Enforce HTTPS** (prevent network interception): + ```ini + session.cookie_secure = 1 + ``` + +2. **HTTPOnly cookies** (prevent XSS theft): + ```ini + session.cookie_httponly = 1 + ``` + +3. **SameSite cookies** (prevent CSRF): + ```ini + session.cookie_samesite = "Strict" + ``` + +4. **Regenerate session ID** after login: + ```php + // After successful authentication + session_regenerate_id(true); + ``` + +5. **IP/User-Agent validation** (advanced): + ```php + $_SESSION['ip'] = $_SERVER['REMOTE_ADDR']; + $_SESSION['user_agent'] = $_SERVER['HTTP_USER_AGENT']; + // Verify on subsequent requests + ``` + +**Residual Risk:** MEDIUM (after mitigations) + +--- + +### Threat: Encryption Key Exposure + +**Risk Level:** CRITICAL + +**Attack Scenario:** +- Encryption key hardcoded in source code committed to Git +- Attacker accesses GitHub repository +- All encrypted sessions can be decrypted + +**Mitigation:** + +1. **Never commit keys** to version control: + ```bash + # Add to .gitignore + echo ".env" >> .gitignore + echo "config/secrets.php" >> .gitignore + ``` + +2. **Use environment variables**: + ```bash + export FS_SESSION_ENCRYPTION_KEY="" + ``` + +3. **Use secrets management** (production): + - AWS Secrets Manager + - HashiCorp Vault + - Azure Key Vault + +4. **Scan for leaked secrets**: + ```bash + # Use git-secrets or similar + git secrets --install + git secrets --register-aws + ``` + +**Residual Risk:** LOW (after mitigations) + +--- + +### Threat: Memory Dumps / Process Inspection + +**Risk Level:** MEDIUM + +**Attack Scenario:** +- Attacker gains access to server with elevated privileges +- Attacker dumps PHP process memory or debugs running process +- Encryption keys and decrypted tokens extracted from memory + +**Reality Check:** +- ❌ **Session encryption DOES NOT protect against this** +- Tokens are plaintext in memory during request processing +- Encryption only protects data **at rest** on disk + +**Mitigation:** + +1. **Operating system security** (primary defense): + - Restrict SSH access (key-based only) + - Disable root login + - Use firewalls (only open necessary ports) + - Keep OS patched + +2. **Principle of least privilege**: + - Web server runs as unprivileged user + - No shell access for web server user + - Restrict sudo access + +3. **Process isolation**: + - Use containers (Docker) or VMs + - SELinux or AppArmor profiles + - PHP-FPM pools per application + +**Residual Risk:** MEDIUM (OS compromise is severe regardless) + +--- + +### Threat: XSS (Cross-Site Scripting) Attacks + +**Risk Level:** HIGH + +**Attack Scenario:** +- Attacker injects malicious JavaScript into your application +- JavaScript steals session cookie or makes API calls as user +- Encryption doesn't prevent this (attack happens client-side) + +**Reality Check:** +- ❌ **Session encryption DOES NOT protect against XSS** +- XSS attacks happen in the browser, not on the server +- Attacker can make authenticated API calls directly + +**Mitigation:** + +1. **HTTPOnly cookies** (prevent cookie theft): + ```ini + session.cookie_httponly = 1 + ``` + +2. **Content Security Policy** (prevent script injection): + ```php + header("Content-Security-Policy: default-src 'self'; script-src 'self';"); + ``` + +3. **Output escaping** (prevent HTML injection): + ```php + echo htmlspecialchars($user_input, ENT_QUOTES, 'UTF-8'); + ``` + +4. **Input validation**: + ```php + $clean_input = filter_var($input, FILTER_SANITIZE_STRING); + ``` + +**Residual Risk:** MEDIUM (XSS is application-specific) + +--- + +## Key Rotation Procedure + +### When to Rotate Keys + +Rotate encryption keys: +- **Every 90 days** (recommended) +- **Immediately** if key compromise suspected +- **After employee departure** (if they had key access) +- **After security incident** + +### Rotation Steps + +**1. Generate new key:** +```bash +NEW_KEY=$(php -r "echo base64_encode(random_bytes(32));") +echo "New key: $NEW_KEY" +``` + +**2. Deploy new key alongside old key:** +```bash +# Keep old key for backward compatibility +export FS_SESSION_ENCRYPTION_KEY_OLD="$FS_SESSION_ENCRYPTION_KEY" +export FS_SESSION_ENCRYPTION_KEY="$NEW_KEY" +``` + +**3. Update application** to try new key first, fallback to old: +```php +// During migration period +$keys = [ + $_ENV['FS_SESSION_ENCRYPTION_KEY'], // New key (primary) + $_ENV['FS_SESSION_ENCRYPTION_KEY_OLD'] // Old key (fallback) +]; + +// Try decryption with each key +foreach ($keys as $key) { + $fs = new FamilySearch([ + 'sessionEncryptionKey' => $key, + 'sessionEncryption' => true + ]); + // If successful, break +} +``` + +**4. Wait for migration period** (7-30 days): +- Users gradually re-authenticate +- Old sessions expire naturally +- New sessions use new key + +**5. Remove old key:** +```bash +unset FS_SESSION_ENCRYPTION_KEY_OLD +``` + +--- + +## Reporting Security Issues + +### Responsible Disclosure + +If you discover a security vulnerability in this SDK, please report it responsibly: + +**DO:** +- ✅ Email security issues privately to: [justincyork@gmail.com](mailto:justincyork@gmail.com) +- ✅ Provide detailed steps to reproduce +- ✅ Include proof-of-concept code (if applicable) +- ✅ Give us reasonable time to fix (90 days) + +**DON'T:** +- ❌ Publicly disclose vulnerabilities before fix is released +- ❌ Exploit vulnerabilities in production systems +- ❌ Demand payment for vulnerability disclosure + +### What to Include in Report + +Please include: +- SDK version affected +- Description of vulnerability +- Steps to reproduce +- Proof-of-concept code +- Potential impact assessment +- Suggested fix (if applicable) + +**Example Report:** + +``` +Subject: [SECURITY] Session Token Exposure via [vector] + +SDK Version: 1.2.0 + +Description: +Under certain conditions, access tokens may be logged in plaintext +when [specific scenario]. + +Steps to Reproduce: +1. Enable debug logging +2. Perform OAuth flow +3. Check logs at /var/log/php-errors.log + +Impact: +Access tokens exposed in log files, potential unauthorized API access. + +Proof of Concept: +[code here] + +Suggested Fix: +Mask tokens in debug output using [approach]. +``` + +### Response Timeline + +We aim to: +- **Acknowledge** your report within 48 hours +- **Provide initial assessment** within 7 days +- **Release a fix** within 90 days (or explain delay) +- **Credit you** in release notes (if desired) + +### Security Advisory Process + +1. **Confirmed vulnerability** → We create private security advisory +2. **Fix developed** → Tested and reviewed +3. **Fix released** → Published with CVE (if applicable) +4. **Public disclosure** → After users have time to update + +--- + +## Additional Resources + +### Security Tools + +- **Secrets scanning:** [git-secrets](https://github.com/awslabs/git-secrets) +- **Dependency scanning:** [composer audit](https://getcomposer.org/doc/03-cli.md#audit) +- **PHP security:** [Snyk](https://snyk.io/), [OWASP Dependency-Check](https://owasp.org/www-project-dependency-check/) + +### Security References + +- [OWASP PHP Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/PHP_Configuration_Cheat_Sheet.html) +- [PHP Session Security](https://www.php.net/manual/en/session.security.php) +- [NIST Cryptographic Standards](https://csrc.nist.gov/publications/detail/sp/800-175b/rev-1/final) +- [FamilySearch API Documentation](https://www.familysearch.org/developers/docs/api/) + +### PHP Security Configuration + +- [PHP Security Guide](https://www.php.net/manual/en/security.php) +- [Session Management](https://www.php.net/manual/en/session.security.management.php) +- [OpenSSL Functions](https://www.php.net/manual/en/ref.openssl.php) + +--- + +**Last Updated:** 2026-08-12 +**SDK Version:** 1.3.0+ +**Encryption Feature:** Since v1.3.0 diff --git a/examples/.env.example b/examples/.env.example new file mode 100644 index 0000000..7d5c988 --- /dev/null +++ b/examples/.env.example @@ -0,0 +1,104 @@ +# FamilySearch PHP Lite SDK - Example Configuration +# ================================================== +# Copy this file to .env and configure your settings +# Do not commit .env to version control +# ================================================== + +# FamilySearch API Key +# --------------------- +# Get your developer app key from: https://www.familysearch.org/developers/ +# Register your application and copy the app key here +FS_APP_KEY=your_familysearch_app_key_here + +# Session Encryption Key (OPTIONAL for examples, REQUIRED for production) +# ------------------------------------------------------------------------- +# Enable encryption to protect OAuth access tokens stored in PHP sessions +# +# Generate a secure encryption key using one of these methods: +# +# Method 1 (Recommended): Base64-encoded 32-byte key +# php -r "echo base64_encode(random_bytes(32));" +# Example output: WdaFfj4iL3Epz2o9phaBbh7FyA5fJs3lCcr6YB4QQxo= +# +# Method 2: Hex-encoded 32-byte key +# php -r "echo bin2hex(random_bytes(32));" +# Example output: 4c0bd859f72d55003baa72e76fea385e599c9562b1b75a1fec0831b19f04118a +# +# Method 3: OpenSSL +# php -r "echo base64_encode(openssl_random_pseudo_bytes(32));" +# +# SECURITY WARNING: +# - Use different keys for dev, staging, and production +# - NEVER commit this file to Git (add .env to .gitignore) +# - Store production keys in your hosting platform's environment variables +# - Rotate keys every 90 days +# +FS_ENCRYPTION_KEY=your_32_byte_encryption_key_here + +# ================================================== +# INSTRUCTIONS +# ================================================== +# +# 1. COPY THIS FILE: +# cp .env.example .env +# +# 2. CONFIGURE YOUR KEYS: +# - Set FS_APP_KEY with your FamilySearch developer app key +# - (Optional) Set FS_ENCRYPTION_KEY for encrypted sessions +# +# 3. LOAD ENVIRONMENT VARIABLES (development): +# +# Option A: PHP built-in +# In your PHP code: +# $env = parse_ini_file('.env'); +# foreach ($env as $key => $value) { +# putenv("$key=$value"); +# } +# +# Option B: Use vlucas/phpdotenv package +# composer require vlucas/phpdotenv +# $dotenv = Dotenv\Dotenv::createImmutable(__DIR__); +# $dotenv->load(); +# +# Option C: Set manually +# export FS_APP_KEY="your-key-here" +# export FS_ENCRYPTION_KEY="your-encryption-key-here" +# +# 4. PRODUCTION DEPLOYMENT: +# DO NOT use .env files in production. Instead, set environment variables +# using your hosting platform: +# +# Heroku: +# heroku config:set FS_APP_KEY="your-key" +# heroku config:set FS_ENCRYPTION_KEY="your-encryption-key" +# +# AWS (EC2, Elastic Beanstalk): +# Use Systems Manager Parameter Store or Secrets Manager +# +# Docker: +# docker run -e FS_APP_KEY="your-key" -e FS_ENCRYPTION_KEY="your-key" ... +# +# Kubernetes: +# Use Kubernetes Secrets +# +# ================================================== +# SECURITY BEST PRACTICES +# ================================================== +# +# ✅ DO: +# - Add .env to .gitignore +# - Use strong, randomly generated encryption keys +# - Use different keys per environment +# - Store production keys in secrets manager +# - Rotate keys periodically (every 90 days) +# +# ❌ DO NOT: +# - Commit .env to version control +# - Hardcode credentials in source code +# - Share keys via email or chat +# - Reuse keys across environments +# - Use weak or predictable keys +# +# For comprehensive security guidance, see: +# https://github.com/FamilySearch/fs-php-lite/blob/master/SECURITY.md +# diff --git a/examples/_includes.php b/examples/_includes.php index 2764aa2..f472529 100644 --- a/examples/_includes.php +++ b/examples/_includes.php @@ -4,12 +4,90 @@ include '../src/FamilySearch.php'; +// ============================================================================= +// SECURITY: Credentials from Environment Variables +// ============================================================================= +// NEVER hardcode credentials in source code. Always use environment variables +// or a secrets management system. Hardcoded credentials can be: +// - Accidentally committed to version control (GitHub, GitLab, etc.) +// - Exposed in error logs or debugging output +// - Discovered by attackers who gain read access to your server +// +// To set environment variables: +// - Development: Copy .env.example to .env and configure +// - Production: Use your hosting platform's environment variable settings +// (Heroku Config Vars, AWS Parameter Store, etc.) +// ============================================================================= + +// Load FamilySearch app key from environment +$appKey = getenv('FS_APP_KEY'); + +// Validate that app key is configured +if (empty($appKey)) { + die(' +

Configuration Error

+

FS_APP_KEY environment variable is not set.

+

To fix this:

+
    +
  1. Copy .env.example to .env in the examples directory
  2. +
  3. Get your FamilySearch developer app key from + + https://www.familysearch.org/developers/
  4. +
  5. Set FS_APP_KEY in your .env file
  6. +
  7. Make sure .env is in your .gitignore (NEVER commit credentials!)
  8. +
+

For production deployment, set environment variables using your hosting platform.

+ '); +} + +// ============================================================================= +// SDK Configuration +// ============================================================================= + +// Basic configuration (suitable for development/testing) +// For production, enable encryption (see commented example below) $fs = new FamilySearch([ 'environment' => 'sandbox', - 'appKey' => 'YOUR_APP_KEY_HERE', // Replace with your FamilySearch developer app key + 'appKey' => $appKey, // From environment variable (secure) 'redirectUri' => calculateBaseUrl() . '/examples/oauthResponse.php', ]); +// ============================================================================= +// PRODUCTION CONFIGURATION (with encryption enabled) +// ============================================================================= +// Uncomment and configure for production deployment: +// +// $encryptionKey = getenv('FS_ENCRYPTION_KEY'); +// +// // Validate encryption key is configured +// if (empty($encryptionKey)) { +// die('FS_ENCRYPTION_KEY environment variable is required for encrypted sessions'); +// } +// +// $fs = new FamilySearch([ +// 'environment' => 'production', // Use 'production' environment +// 'appKey' => $appKey, +// 'redirectUri' => calculateBaseUrl() . '/examples/oauthResponse.php', +// +// // Enable AES-256-GCM encryption for session tokens (RECOMMENDED for production) +// 'sessionEncryption' => true, +// 'sessionEncryptionKey' => $encryptionKey, // From environment variable (secure) +// ]); +// +// Why enable encryption? +// - Protects access tokens from filesystem access (backups, logs, disk forensics) +// - Required for compliance (PCI DSS, GDPR, etc.) +// - Defense-in-depth security strategy +// +// Generate encryption key: +// php -r "echo base64_encode(random_bytes(32));" +// +// Store in .env file: +// FS_ENCRYPTION_KEY=WdaFfj4iL3Epz2o9phaBbh7FyA5fJs3lCcr6YB4QQxo= +// +// See SECURITY.md for comprehensive security guidance. +// ============================================================================= + /** * Pretty print a PHP variable * diff --git a/src/FamilySearch.php b/src/FamilySearch.php index 9ba9c8b..f4aa1bb 100644 --- a/src/FamilySearch.php +++ b/src/FamilySearch.php @@ -3,10 +3,18 @@ /** * Basic PHP SDK for the FamilySearch API. */ -class FamilySearch +class FamilySearch { - - const VERSION = '1.2.0'; + + /** + * SDK version number (Semantic Versioning) + * + * Version 1.3.0: Added optional AES-256-GCM session token encryption + * - New feature: sessionEncryption and sessionEncryptionKey configuration options + * - Backward compatible: encryption defaults to disabled (no breaking changes) + * - Security enhancement: protects OAuth tokens from filesystem disclosure + */ + const VERSION = '1.3.0'; /** * The FamilySearch reference or environment to target. Valid values are @@ -41,14 +49,90 @@ class FamilySearch /** * Name of the session variable that the access token will be saved in. * Defaults to 'FS_ACCESS_TOKEN' - * + * * @var string */ private $sessionVariable = 'FS_ACCESS_TOKEN'; - + + /** + * Whether to encrypt access tokens stored in $_SESSION using AES-256-GCM. + * + * When enabled, OAuth access tokens are encrypted using AES-256-GCM authenticated + * encryption before being stored in PHP session files. This protects tokens from + * unauthorized filesystem access, backup exposure, and disk forensics. + * + * Default: false (for backward compatibility with existing applications) + * + * Security Implications: + * - When false: Tokens stored in plaintext (INSECURE for production) + * - When true: Tokens encrypted with AES-256-GCM (RECOMMENDED for production) + * + * What encryption protects against: + * - Filesystem access to session files + * - Backup file exposure + * - Disk forensics after deletion + * - Accidental logging of session data + * + * What encryption does NOT protect against: + * - Active server compromise (attacker can access encryption key) + * - Memory dumps (tokens are plaintext in memory during request) + * - XSS attacks (client-side attacks) + * - Session hijacking (valid session ID grants access) + * + * @var bool + * @see $sessionEncryptionKey for key requirements + * @see SECURITY.md for comprehensive security guidance + */ + private $sessionEncryption = false; + + /** + * Encryption key for session token encryption. + * + * This key is used to encrypt and decrypt OAuth access tokens stored in PHP sessions. + * AES-256-GCM requires exactly 32 bytes (256 bits) for the encryption key. + * + * Required: Yes, when $sessionEncryption is enabled + * + * Key Format Support: + * - Raw binary: 32 bytes (used directly) + * - Base64: 44 characters (decoded to 32 bytes) + * - Hexadecimal: 64 characters (decoded to 32 bytes) + * - Passphrase: Any other length (hashed with SHA-256 to 32 bytes) + * + * Key Generation (Recommended): + * ```php + * // Method 1: Base64-encoded key (recommended) + * $key = base64_encode(random_bytes(32)); + * + * // Method 2: Hex-encoded key + * $key = bin2hex(random_bytes(32)); + * + * // Method 3: Using OpenSSL + * $key = base64_encode(openssl_random_pseudo_bytes(32)); + * ``` + * + * Key Storage Best Practices: + * - NEVER hardcode keys in source code + * - Store in environment variables: $_ENV['FS_SESSION_ENCRYPTION_KEY'] + * - Use secrets manager in production (AWS Secrets Manager, HashiCorp Vault) + * - Use different keys per environment (dev/staging/production) + * - Rotate keys every 90 days + * - Backup keys securely (encrypted backups only) + * + * Security Warning: + * If this key is compromised, all encrypted session tokens can be decrypted. + * Treat this key with the same security level as the tokens it protects. + * + * @var string|null 32-byte encryption key (may be encoded as base64/hex) + * @see $sessionEncryption to enable encryption + * @see normalizeEncryptionKey() for key format handling + * @see SECURITY.md for key management best practices + */ + private $sessionEncryptionKey; + /** * Access token returned by OAuth - * + * * @var string */ private $accessToken; @@ -83,8 +167,28 @@ class FamilySearch /** * Construct a new FamilySearch Client - * - * @param array $options + * + * @param array $options Configuration options + * @param string $options['environment'] Environment: 'production', 'beta', or 'integration' (default: 'integration') + * @param string $options['appKey'] Application key from FamilySearch developer portal + * @param string $options['redirectUri'] OAuth redirect URI for authorization flow + * @param bool $options['sessions'] Enable automatic session storage of access token (default: true) + * @param string $options['sessionVariable'] Session variable name for token storage (default: 'FS_ACCESS_TOKEN') + * @param bool $options['sessionEncryption'] Enable AES-256-GCM encryption for session tokens (default: false) + * @param string $options['sessionEncryptionKey'] Encryption key for session tokens (required if sessionEncryption=true) + * - If exactly 32 bytes: used directly as AES-256 key + * - If 44 characters: decoded as base64 to 32 bytes + * - If 64 characters: decoded as hex to 32 bytes + * - Other lengths: derived using hash('sha256', $key, true) + * Generate secure key: base64_encode(random_bytes(32)) + * @param string $options['accessToken'] Manually provide access token (bypasses session storage) + * @param int $options['maxThrottledRetries'] Maximum retry attempts for throttled requests (default: 5) + * @param array $options['pendingModifications'] Array of pending modification feature flags + * @param string $options['userAgent'] Additional user agent string to append to default + * @param bool $options['objects'] Enable gedcomx-php object serialization/deserialization (default: false) + * + * @throws \InvalidArgumentException if sessionEncryption is enabled but sessionEncryptionKey is missing + * @throws \Exception if OpenSSL extension is not available when encryption is enabled */ public function __construct($options = array()) { @@ -107,7 +211,49 @@ public function __construct($options = array()) if (isset($options['sessionVariable'])) { $this->sessionVariable = $options['sessionVariable']; } - + + // ===================================================================== + // Session Encryption Configuration + // ===================================================================== + // Load encryption settings from options array + if (isset($options['sessionEncryption']) && is_bool($options['sessionEncryption'])) { + $this->sessionEncryption = $options['sessionEncryption']; + } + + if (isset($options['sessionEncryptionKey'])) { + $this->sessionEncryptionKey = $options['sessionEncryptionKey']; + } + + // ===================================================================== + // Validate Encryption Configuration at Construction Time + // ===================================================================== + // If encryption is enabled, validate requirements immediately to fail fast + // and provide clear error messages to developers during setup + if ($this->sessionEncryption) { + // SECURITY CHECK: Ensure OpenSSL extension is available + // AES-256-GCM encryption requires the OpenSSL PHP extension + if (!extension_loaded('openssl')) { + throw new \Exception( + 'Session token encryption requires the OpenSSL PHP extension. ' . + 'Please install or enable the OpenSSL extension, or disable sessionEncryption.' + ); + } + + // SECURITY CHECK: Ensure encryption key is provided + // Without a key, we cannot perform encryption - fail immediately + if (empty($this->sessionEncryptionKey)) { + throw new \InvalidArgumentException( + 'sessionEncryptionKey is required when sessionEncryption is enabled. ' . + 'Generate a secure key using: base64_encode(random_bytes(32))' + ); + } + + // KEY NORMALIZATION: Convert key to standard 32-byte format + // Supports multiple input formats: raw binary, base64, hex, or passphrase + // After normalization, key is always exactly 32 bytes for AES-256 + $this->sessionEncryptionKey = $this->normalizeEncryptionKey($this->sessionEncryptionKey); + } + if (isset($options['pendingModifications'])) { $this->pendingModifications = implode(',', $options['pendingModifications']); } @@ -117,10 +263,103 @@ public function __construct($options = array()) $this->userAgent .= ' ' . $options['userAgent']; } + // ===================================================================== + // Session Token Retrieval with Encryption Support + // ===================================================================== // Load the access token from the session first so that it can be // overwritten by the accessToken option + // + // This logic handles four scenarios for backward compatibility: + // 1. Encrypted token + encryption enabled (normal operation) + // 2. Encrypted token + encryption disabled (downgrade - clear session) + // 3. Plaintext token + encryption enabled (migration - accept temporarily) + // 4. Plaintext token + encryption disabled (legacy - normal operation) if ($this->sessions && isset($_SESSION[$this->sessionVariable])) { - $this->accessToken = $_SESSION[$this->sessionVariable]; + $sessionValue = $_SESSION[$this->sessionVariable]; + $isEncrypted = $this->isEncryptedToken($sessionValue); + + // ================================================================ + // SECURITY: Determine whether to decrypt based on token format and configuration + // This provides backward compatibility and graceful migration handling + // ================================================================ + + if ($isEncrypted) { + // ============================================================= + // SCENARIO 1 & 2: Token appears to be encrypted + // ============================================================= + if ($this->sessionEncryption) { + // SCENARIO 1: Encryption enabled + encrypted token (EXPECTED) + // This is normal operation - decrypt the token + try { + $decrypted = $this->decryptToken($sessionValue); + if ($decrypted !== false) { + $this->accessToken = $decrypted; + } else { + // SECURITY FAILURE: Decryption returned false + // Possible causes: + // - Wrong encryption key + // - Corrupted ciphertext + // - Tampered authentication tag + // - Invalid encrypted format + // + // FAIL SECURE: Clear session and require re-authentication + unset($_SESSION[$this->sessionVariable]); + $this->accessToken = null; + trigger_error( + 'Failed to decrypt session token. Session cleared. ' . + 'This may indicate wrong encryption key or corrupted session data.', + E_USER_WARNING + ); + } + } catch (\Exception $e) { + // SECURITY EXCEPTION: Decryption threw exception + // This indicates a serious error (missing OpenSSL, invalid key, etc.) + // + // FAIL SECURE: Clear session and fail closed + // NEVER expose the token or key in error messages + unset($_SESSION[$this->sessionVariable]); + $this->accessToken = null; + trigger_error( + 'Session token decryption error: ' . $e->getMessage() . '. Session cleared.', + E_USER_WARNING + ); + } + } else { + // SCENARIO 2: Encryption disabled + encrypted token (DOWNGRADE) + // User disabled encryption but session contains encrypted token + // This happens when encryption is turned off after being enabled + // + // SECURITY POLICY: Cannot decrypt without key + // FAIL SECURE: Clear session and require re-authentication + // This prevents accidental exposure if encryption was disabled by mistake + unset($_SESSION[$this->sessionVariable]); + $this->accessToken = null; + trigger_error( + 'Encrypted session token found but encryption is disabled. ' . + 'Session cleared. Re-authentication required.', + E_USER_WARNING + ); + } + } else { + // ============================================================= + // SCENARIO 3 & 4: Token appears to be plaintext + // ============================================================= + if ($this->sessionEncryption) { + // SCENARIO 3: Encryption enabled + plaintext token (MIGRATION) + // User enabled encryption but session contains plaintext token + // This is expected during migration from plaintext to encrypted storage + // + // MIGRATION STRATEGY: Accept plaintext token temporarily + // On next OAuth response, token will be encrypted automatically + // This provides zero-downtime migration without forcing logout + $this->accessToken = $sessionValue; + } else { + // SCENARIO 4: Encryption disabled + plaintext token (LEGACY) + // This is normal legacy operation before encryption was enabled + // Token is stored in plaintext in session (not recommended for production) + $this->accessToken = $sessionValue; + } + } } if (isset($options['accessToken'])) { @@ -202,16 +441,75 @@ public function oauthPassword($username, $password) } /** - * Common handler for a successful OAuth2 access token response - * - * @param object $response - * @returns object response + * Common handler for a successful OAuth2 access token response. + * + * This method is called after successfully exchanging an OAuth authorization code + * or password credentials for an access token. It stores the token in memory and + * optionally persists it to the PHP session with optional encryption. + * + * Token Storage Strategy: + * - If encryption enabled: Token is encrypted with AES-256-GCM before storage + * - If encryption disabled: Token is stored in plaintext (not recommended for production) + * - If encryption fails: Token is NOT stored (fail-secure), available for current request only + * + * Security Considerations: + * - Token is always stored in memory ($this->accessToken) regardless of session storage + * - If encryption fails, we do NOT fall back to plaintext storage (fail-secure principle) + * - User must re-authenticate on next request if encryption fails + * + * @param object $response The OAuth token response from FamilySearch API + * @return object The same response object (for method chaining or inspection) */ private function oauthResponseHandler($response){ if ($response->statusCode === 200) { + // Extract and store access token in memory (always available for current request) $this->accessToken = $response->data['access_token']; + + // ================================================================ + // Session Token Storage with Optional Encryption + // ================================================================ if ($this->sessions) { - $_SESSION[$this->sessionVariable] = $this->accessToken; + if ($this->sessionEncryption) { + // ======================================================== + // SECURE PATH: Encryption enabled - encrypt before storing + // ======================================================== + try { + // Encrypt the access token using AES-256-GCM authenticated encryption + // Format: base64(iv):base64(tag):base64(ciphertext) + // - IV (12 bytes): Unique random initialization vector + // - Tag (16 bytes): Authentication tag for tamper detection + // - Ciphertext: Encrypted token data + $encryptedToken = $this->encryptToken($this->accessToken); + $_SESSION[$this->sessionVariable] = $encryptedToken; + } catch (\Exception $e) { + // ==================================================== + // FAIL-SECURE: Encryption failed - DO NOT store plaintext + // ==================================================== + // If encryption fails (missing OpenSSL, invalid key, etc.), + // we NEVER fall back to storing the token in plaintext. + // + // This is a critical security decision: + // - Better to require re-authentication than expose tokens + // - Token remains available in memory for current request + // - User must authenticate again on next request + // + // This ensures we fail secure rather than degrading to insecure storage + trigger_error( + 'Failed to encrypt session token: ' . $e->getMessage() . '. ' . + 'Token not stored in session (available for current request only).', + E_USER_WARNING + ); + // Explicitly clear any existing session value to prevent confusion + unset($_SESSION[$this->sessionVariable]); + } + } else { + // ======================================================== + // LEGACY PATH: Encryption disabled - store plaintext + // ======================================================== + // This is the legacy behavior before encryption was implemented + // NOT RECOMMENDED for production environments + $_SESSION[$this->sessionVariable] = $this->accessToken; + } } } return $response; @@ -581,12 +879,439 @@ private function platformHost() /** * Calculate the default user agent - * + * * @return string */ private static function defaultUseragent() { return 'FS-PHP-Lite/' . self::VERSION . ' curl/' . \curl_version()['version'] . ' PHP/' . PHP_VERSION; } - + + /** + * Detect if a session value appears to be encrypted. + * + * This method checks if a session token value is in the encrypted format used by + * encryptToken(). The encrypted format consists of three colon-separated base64 + * segments: IV:tag:ciphertext. This detection is used for backward compatibility + * to handle mixed scenarios (encrypted tokens with encryption disabled, or + * plaintext tokens with encryption enabled during migration). + * + * Detection Strategy: + * - Encrypted tokens have exactly 2 colons (3 segments) + * - Each segment should be valid base64 + * - Plaintext tokens typically don't contain colons or have different structure + * + * Security Considerations: + * - False positives are acceptable: attempting to decrypt a plaintext token will fail + * safely and the token will be treated as invalid + * - False negatives are critical: missing an encrypted token could expose it + * - The format is intentionally distinctive to minimize detection errors + * + * @param mixed $value Session value to check + * @return bool True if value appears to be encrypted, false otherwise + */ + private function isEncryptedToken($value) + { + // Must be a string to be encrypted + if (!is_string($value) || empty($value)) { + return false; + } + + // Encrypted format has exactly 3 segments separated by colons (2 colons total) + // Format: base64(iv):base64(tag):base64(ciphertext) + if (substr_count($value, ':') !== 2) { + return false; + } + + // Split and verify we have exactly 3 parts + $parts = explode(':', $value); + if (count($parts) !== 3) { + return false; + } + + // All parts should be non-empty base64 strings + // We don't strictly validate base64 here as decryptToken() will handle that + foreach ($parts as $part) { + if (empty($part)) { + return false; + } + } + + return true; + } + + /** + * Generate a cryptographically secure initialization vector (IV) for AES-GCM encryption. + * + * The IV is used to ensure that the same plaintext encrypted multiple times produces + * different ciphertexts. For AES-GCM, the optimal IV length is 96 bits (12 bytes) per + * NIST SP 800-38D. Each encryption operation MUST use a unique IV to maintain security. + * + * Security Considerations: + * - Uses openssl_random_pseudo_bytes() for cryptographically secure randomness + * - IV does not need to be secret and is stored alongside the ciphertext + * - NEVER reuse an IV with the same encryption key + * + * @return string 12-byte binary IV + * @throws \Exception if secure random bytes cannot be generated + */ + private function generateIV() + { + $iv = openssl_random_pseudo_bytes(12, $cryptoStrong); + + if ($iv === false || !$cryptoStrong) { + throw new \Exception( + 'Failed to generate cryptographically secure IV. ' . + 'OpenSSL random number generator may not be properly seeded.' + ); + } + + return $iv; + } + + /** + * Normalize an encryption key to exactly 32 bytes for AES-256. + * + * This method accepts encryption keys in multiple formats and normalizes them to the + * 32-byte (256-bit) format required by AES-256. This provides flexibility in how keys + * are provided while ensuring cryptographic compatibility. + * + * Key Format Handling: + * - Raw binary (32 bytes): Used directly without modification + * - Base64 (44 characters): Decoded to 32 bytes (e.g., output of base64_encode(random_bytes(32))) + * - Hexadecimal (64 characters): Decoded to 32 bytes (e.g., output of bin2hex(random_bytes(32))) + * - Other lengths: Derived using SHA-256 hash to produce exactly 32 bytes + * + * Key Derivation for Non-Standard Lengths: + * When a key is not in one of the standard formats, SHA-256 hashing is used to derive + * a 32-byte key. This allows passphrases or keys of arbitrary length to be converted + * into a valid AES-256 key. However, for maximum security, prefer using properly + * generated 32-byte random keys rather than relying on hash derivation. + * + * Security Considerations: + * - Prefer pre-generated 32-byte keys: Use random_bytes(32) for maximum entropy + * - Hash derivation reduces entropy: Passphrases have lower entropy than random keys + * - Use different keys per environment: Never share keys between dev/staging/production + * - Store keys securely: Use environment variables or secure vaults, never in source code + * + * Recommended Key Generation: + * ```php + * $key = base64_encode(random_bytes(32)); // Generates 44-character base64 key + * ``` + * + * @param string $key Encryption key in any supported format + * @return string Normalized 32-byte binary encryption key + */ + private function normalizeEncryptionKey($key) + { + // Check if already 32 bytes (raw binary format) + if (strlen($key) === 32) { + return $key; + } + + // Attempt to decode from base64 (44 characters produces 32 bytes) + if (strlen($key) === 44) { + $decoded = base64_decode($key, true); + if ($decoded !== false && strlen($decoded) === 32) { + return $decoded; + } + } + + // Attempt to decode from hexadecimal (64 characters produces 32 bytes) + if (strlen($key) === 64 && ctype_xdigit($key)) { + $decoded = hex2bin($key); + if ($decoded !== false && strlen($decoded) === 32) { + return $decoded; + } + } + + // For any other length, derive a 32-byte key using SHA-256 + // This allows passphrases and arbitrary-length keys to be used + return hash('sha256', $key, true); + } + + /** + * Validate that the encryption key meets AES-256 requirements. + * + * This method validates that the configured encryption key is present and exactly + * 32 bytes in length. The key should already be normalized by normalizeEncryptionKey() + * during construction, so this method primarily serves as a runtime assertion. + * + * Note: This method is called by encryptToken() and decryptToken() to ensure the + * encryption key is available and valid before performing cryptographic operations. + * The key normalization (format conversion) happens in the constructor via + * normalizeEncryptionKey(), so this method expects a ready-to-use 32-byte key. + * + * Security Considerations: + * - Key must have high entropy (use random_bytes() or openssl_random_pseudo_bytes()) + * - Never hardcode keys in source code + * - Store keys in environment variables or secure configuration files + * - Use different keys for different environments (dev/staging/production) + * + * @return string Validated 32-byte binary key + * @throws \InvalidArgumentException if key is missing or not exactly 32 bytes + */ + private function validateEncryptionKey() + { + if (empty($this->sessionEncryptionKey)) { + throw new \InvalidArgumentException( + 'sessionEncryptionKey is required when sessionEncryption is enabled. ' . + 'Generate a secure key using: base64_encode(random_bytes(32))' + ); + } + + $key = $this->sessionEncryptionKey; + + // Key should already be normalized to 32 bytes in constructor + if (strlen($key) !== 32) { + throw new \InvalidArgumentException( + 'sessionEncryptionKey must be exactly 32 bytes (256 bits) for AES-256. ' . + 'Received: ' . strlen($key) . ' bytes. ' . + 'This indicates the key was not properly normalized during construction.' + ); + } + + return $key; + } + + /** + * Encrypt an access token using AES-256-GCM authenticated encryption. + * + * This method encrypts OAuth access tokens before storing them in $_SESSION to protect + * against file disclosure attacks, unauthorized disk access, and accidental logging. + * AES-256-GCM provides both confidentiality (encryption) and authenticity (prevents tampering). + * + * Encryption Process: + * 1. Generate a unique random IV (12 bytes) for this encryption + * 2. Encrypt the token using AES-256-GCM with the configured key + * 3. Obtain the authentication tag (16 bytes) for integrity verification + * 4. Combine IV, tag, and ciphertext into a single encoded string + * 5. Return base64-encoded format: base64(IV):base64(tag):base64(ciphertext) + * + * Security Considerations: + * - Each encryption uses a unique IV (never reused) + * - Authentication tag prevents ciphertext tampering + * - Encrypted data is self-contained (includes all components needed for decryption) + * - Format is easily distinguishable from plaintext tokens (contains colons) + * + * Threat Model: + * - PROTECTS AGAINST: File system disclosure, disk forensics, session storage dumps + * - DOES NOT PROTECT: Memory dumps, active code execution, compromised encryption key + * + * @param string $token Plaintext OAuth access token to encrypt + * @return string Encrypted token in format: base64(iv):base64(tag):base64(ciphertext) + * @throws \Exception if encryption fails or OpenSSL is not available + */ + private function encryptToken($token) + { + if (!extension_loaded('openssl')) { + throw new \Exception( + 'OpenSSL extension is required for session token encryption. ' . + 'Please install or enable the OpenSSL PHP extension.' + ); + } + + // ===================================================================== + // STEP 1: Validate encryption key is present and correct length + // ===================================================================== + $keyBinary = $this->validateEncryptionKey(); + + // ===================================================================== + // STEP 2: Generate unique cryptographically secure IV + // ===================================================================== + // CRITICAL: Each encryption MUST use a unique IV with the same key + // Reusing IVs with GCM mode completely breaks security + // IV is 12 bytes (96 bits) - optimal for AES-GCM per NIST SP 800-38D + $iv = $this->generateIV(); + + // ===================================================================== + // STEP 3: Perform AES-256-GCM authenticated encryption + // ===================================================================== + // AES-256-GCM provides both: + // - CONFIDENTIALITY: Token is encrypted (unreadable without key) + // - AUTHENTICITY: Authentication tag detects any tampering + // + // Why GCM mode? + // - Built-in authentication (no separate HMAC needed) + // - AEAD (Authenticated Encryption with Associated Data) + // - No padding oracle vulnerabilities (unlike CBC mode) + // - Hardware acceleration on modern CPUs (AES-NI) + // + // The $tag parameter is passed by reference and populated by openssl_encrypt + $tag = ''; + $ciphertext = openssl_encrypt( + $token, // Plaintext token to encrypt + 'aes-256-gcm', // Algorithm: AES-256 in GCM mode + $keyBinary, // 32-byte encryption key + OPENSSL_RAW_DATA, // Return raw binary (not base64) + $iv, // 12-byte initialization vector + $tag, // Output: 16-byte authentication tag (by reference) + '', // Additional authenticated data (AAD) - not used + 16 // Tag length: 16 bytes (128 bits) - maximum for GCM + ); + + // Validate encryption succeeded + if ($ciphertext === false) { + throw new \Exception( + 'Failed to encrypt access token. OpenSSL error: ' . openssl_error_string() + ); + } + + // Validate authentication tag was generated (paranoid check) + if (empty($tag)) { + throw new \Exception( + 'Failed to generate authentication tag during encryption. ' . + 'This should not happen with AES-GCM mode.' + ); + } + + // ===================================================================== + // STEP 4: Combine IV, tag, and ciphertext into self-contained format + // ===================================================================== + // All three components are needed for decryption: + // - IV (12 bytes): Must be unique per encryption, not secret + // - Tag (16 bytes): Authentication tag for tamper detection + // - Ciphertext (variable): Encrypted token data + // + // Format: base64(iv):base64(tag):base64(ciphertext) + // Base64 encoding makes it safe to store in session (no binary issues) + // Colon separators make format easily distinguishable from plaintext tokens + $encryptedData = base64_encode($iv) . ':' . base64_encode($tag) . ':' . base64_encode($ciphertext); + + return $encryptedData; + } + + /** + * Decrypt an access token that was encrypted using AES-256-GCM. + * + * This method decrypts OAuth access tokens that were previously encrypted by encryptToken(). + * It validates the authentication tag to ensure the ciphertext has not been tampered with, + * then decrypts the token using the configured encryption key. + * + * Decryption Process: + * 1. Parse the encrypted data format: base64(IV):base64(tag):base64(ciphertext) + * 2. Decode each component from base64 to binary + * 3. Validate the authentication tag (detects tampering) + * 4. Decrypt the ciphertext using AES-256-GCM with the configured key and IV + * 5. Return the plaintext token or false on failure + * + * Security Considerations: + * - Authentication tag is validated before decryption (prevents tampering) + * - Returns false on ANY decryption failure (wrong key, corrupted data, tampered ciphertext) + * - Does not leak information about WHY decryption failed (timing-safe failure) + * - Failed decryption should trigger session clearing and re-authentication + * + * Failure Scenarios: + * - Wrong encryption key + * - Corrupted ciphertext + * - Modified authentication tag + * - Invalid format (not 3 colon-separated segments) + * - Malformed base64 encoding + * + * @param string $encryptedData Encrypted token in format: base64(iv):base64(tag):base64(ciphertext) + * @return string|false Plaintext access token on success, false on decryption failure + */ + private function decryptToken($encryptedData) + { + // ===================================================================== + // PRE-CHECK: Ensure OpenSSL extension is available + // ===================================================================== + if (!extension_loaded('openssl')) { + // Cannot decrypt without OpenSSL - return false (fail safely) + return false; + } + + // ===================================================================== + // STEP 1: Validate encryption key + // ===================================================================== + try { + $keyBinary = $this->validateEncryptionKey(); + } catch (\InvalidArgumentException $e) { + // Invalid key configuration - cannot decrypt + // Return false instead of throwing to fail gracefully + return false; + } + + // ===================================================================== + // STEP 2: Parse encrypted data format + // ===================================================================== + // Expected format: base64(iv):base64(tag):base64(ciphertext) + // Example: "mXzK9PqW3hN8fG2D:aG4k...J9mQ==:pL8nM...vR4==" + $parts = explode(':', $encryptedData); + + if (count($parts) !== 3) { + // Invalid format - must have exactly 3 colon-separated segments + // Could be plaintext token or corrupted encrypted data + return false; + } + + // ===================================================================== + // STEP 3: Decode base64 components to binary + // ===================================================================== + // Strict mode (true) ensures proper base64 validation + $iv = base64_decode($parts[0], true); // 12-byte IV + $tag = base64_decode($parts[1], true); // 16-byte authentication tag + $ciphertext = base64_decode($parts[2], true); // Encrypted token (variable length) + + // Validate base64 decoding succeeded + if ($iv === false || $tag === false || $ciphertext === false) { + // Malformed base64 encoding - corrupted data + return false; + } + + // ===================================================================== + // STEP 4: Validate component lengths + // ===================================================================== + // GCM mode requires specific IV and tag lengths + if (strlen($iv) !== 12 || strlen($tag) !== 16) { + // Invalid IV (expected: 12 bytes) or tag (expected: 16 bytes) + // This indicates corrupted or tampered data + return false; + } + + // ===================================================================== + // STEP 5: Perform AES-256-GCM authenticated decryption + // ===================================================================== + // GCM mode validates the authentication tag BEFORE decryption + // If tag doesn't match, decryption fails (tamper detection) + // + // Decryption can fail for multiple reasons: + // - Wrong encryption key + // - Tampered ciphertext + // - Modified authentication tag + // - Corrupted data + // + // SECURITY: We intentionally return the same error (false) for ALL failures + // This prevents attackers from distinguishing between failure causes + // (timing-safe error handling) + $plaintext = openssl_decrypt( + $ciphertext, // Encrypted token data + 'aes-256-gcm', // Algorithm: AES-256 in GCM mode + $keyBinary, // 32-byte decryption key + OPENSSL_RAW_DATA, // Input/output is raw binary (not base64) + $iv, // 12-byte initialization vector (from encrypted data) + $tag, // 16-byte authentication tag (validates integrity) + '' // Additional authenticated data (AAD) - must match encryption (empty) + ); + + // ===================================================================== + // STEP 6: Validate decryption succeeded + // ===================================================================== + // openssl_decrypt returns false on ANY failure: + // - Wrong key → false + // - Tampered ciphertext → false (tag validation fails) + // - Corrupted data → false + // - Modified tag → false + // + // SECURITY: Same error response for all failure types (timing-safe) + if ($plaintext === false) { + // Decryption failed - return false to trigger session clearing + // Caller will handle this by clearing session and requiring re-auth + return false; + } + + // Success: Return plaintext token + return $plaintext; + } + } \ No newline at end of file diff --git a/tests/Integration/EncryptedSessionFlowTest.php b/tests/Integration/EncryptedSessionFlowTest.php new file mode 100644 index 0000000..a03b3b6 --- /dev/null +++ b/tests/Integration/EncryptedSessionFlowTest.php @@ -0,0 +1,490 @@ +testKey = base64_encode(random_bytes(32)); + $this->testToken = 'test-access-token-' . bin2hex(random_bytes(16)); + + // Mock $_SESSION array (don't actually start session in PHPUnit) + if (!isset($_SESSION)) { + $_SESSION = []; + } + + // Clear session data + if (isset($_SESSION['FS_ACCESS_TOKEN'])) { + unset($_SESSION['FS_ACCESS_TOKEN']); + } + } + + protected function tearDown(): void + { + // Clean up session data + if (isset($_SESSION['FS_ACCESS_TOKEN'])) { + unset($_SESSION['FS_ACCESS_TOKEN']); + } + } + + /** + * Helper to simulate OAuth response + */ + private function simulateOAuthResponse(FamilySearch $fs, string $token): object + { + $mockResponse = new \stdClass(); + $mockResponse->statusCode = 200; + $mockResponse->data = ['access_token' => $token]; + + // Invoke private oauthResponseHandler + $reflection = new ReflectionClass($fs); + $method = $reflection->getMethod('oauthResponseHandler'); + $method->setAccessible(true); + + return $method->invoke($fs, $mockResponse); + } + + // ======================================================================== + // Basic Flow Tests + // ======================================================================== + + public function testOAuthFlowWithEncryptionEnabled(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + // Simulate OAuth response + $response = $this->simulateOAuthResponse($fs, $this->testToken); + + // Verify response + $this->assertEquals(200, $response->statusCode); + + // Verify token is stored in session + $this->assertArrayHasKey('FS_ACCESS_TOKEN', $_SESSION, 'Token should be stored in session'); + + // Verify token is encrypted (has colon separators) + $storedValue = $_SESSION['FS_ACCESS_TOKEN']; + $this->assertIsString($storedValue); + $this->assertEquals(2, substr_count($storedValue, ':'), 'Stored token should be encrypted (3 segments)'); + $this->assertNotEquals($this->testToken, $storedValue, 'Stored token should not be plaintext'); + + // Verify token can be retrieved + $this->assertEquals($this->testToken, $fs->getAccessToken(), 'Token should be retrievable from memory'); + } + + public function testTokenPersistsAcrossRequestsWhenEncrypted(): void + { + // First request: Authenticate and store token + $fs1 = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + $this->simulateOAuthResponse($fs1, $this->testToken); + + $this->assertEquals($this->testToken, $fs1->getAccessToken(), 'Token should be available in first request'); + + // Second request: Load token from session + $fs2 = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + $this->assertEquals($this->testToken, $fs2->getAccessToken(), 'Token should be loaded from session in second request'); + } + + public function testOAuthFlowWithEncryptionDisabled(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => false + ]); + + // Simulate OAuth response + $response = $this->simulateOAuthResponse($fs, $this->testToken); + + // Verify response + $this->assertEquals(200, $response->statusCode); + + // Verify token is stored in session as plaintext + $this->assertArrayHasKey('FS_ACCESS_TOKEN', $_SESSION); + $this->assertEquals($this->testToken, $_SESSION['FS_ACCESS_TOKEN'], 'Token should be stored as plaintext'); + } + + public function testTokenPersistsAcrossRequestsWhenPlaintext(): void + { + // First request: Authenticate and store plaintext token + $fs1 = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => false + ]); + + $this->simulateOAuthResponse($fs1, $this->testToken); + + // Second request: Load plaintext token from session + $fs2 = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => false + ]); + + $this->assertEquals($this->testToken, $fs2->getAccessToken(), 'Plaintext token should persist across requests'); + } + + // ======================================================================== + // Backward Compatibility Tests + // ======================================================================== + + public function testPlaintextTokenWithEncryptionDisabled(): void + { + // Store plaintext token in session + $_SESSION['FS_ACCESS_TOKEN'] = $this->testToken; + + // Load with encryption disabled + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => false + ]); + + $this->assertEquals($this->testToken, $fs->getAccessToken(), 'Plaintext token should be loaded when encryption disabled'); + } + + public function testMigrationEnableEncryptionOnExistingPlaintextSession(): void + { + // Step 1: Store plaintext token (legacy scenario) + $_SESSION['FS_ACCESS_TOKEN'] = $this->testToken; + + // Step 2: Enable encryption (migration scenario) + $fs1 = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + // Plaintext token should be accepted + $this->assertEquals($this->testToken, $fs1->getAccessToken(), 'Plaintext token should be accepted during migration'); + + // Step 3: Simulate OAuth response to encrypt the token + $this->simulateOAuthResponse($fs1, $this->testToken); + + // Verify token is now encrypted in session + $storedValue = $_SESSION['FS_ACCESS_TOKEN']; + $this->assertEquals(2, substr_count($storedValue, ':'), 'Token should now be encrypted'); + + // Step 4: New request should load encrypted token + $fs2 = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + $this->assertEquals($this->testToken, $fs2->getAccessToken(), 'Encrypted token should be loaded after migration'); + } + + public function testDowngradeEncryptedTokenWithEncryptionDisabled(): void + { + // Step 1: Store encrypted token + $fs1 = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + $this->simulateOAuthResponse($fs1, $this->testToken); + + // Verify token is encrypted + $this->assertNotEquals($this->testToken, $_SESSION['FS_ACCESS_TOKEN']); + + // Step 2: Disable encryption (downgrade scenario) + // Suppress expected warning + set_error_handler(function () {}, E_USER_WARNING); + + $fs2 = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => false + ]); + + restore_error_handler(); + + // Encrypted token should not be loaded + $this->assertNull($fs2->getAccessToken(), 'Encrypted token should not be loaded when encryption disabled'); + + // Session should be cleared + $this->assertArrayNotHasKey('FS_ACCESS_TOKEN', $_SESSION, 'Session should be cleared on downgrade'); + } + + // ======================================================================== + // Error Handling Tests + // ======================================================================== + + public function testCorruptedEncryptedSessionDataIsCleared(): void + { + // Store corrupted encrypted data + $_SESSION['FS_ACCESS_TOKEN'] = 'corrupted:encrypted:data'; + + // Suppress expected warning + set_error_handler(function () {}, E_USER_WARNING); + + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + restore_error_handler(); + + // Token should not be loaded + $this->assertNull($fs->getAccessToken(), 'Corrupted data should not be loaded'); + + // Session should be cleared + $this->assertArrayNotHasKey('FS_ACCESS_TOKEN', $_SESSION, 'Corrupted session data should be cleared'); + } + + public function testDecryptionWithWrongKeyFailsAndClearsSession(): void + { + // Step 1: Store token with key 1 + $key1 = base64_encode(random_bytes(32)); + $fs1 = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $key1 + ]); + $this->simulateOAuthResponse($fs1, $this->testToken); + + // Verify token is encrypted + $encryptedValue = $_SESSION['FS_ACCESS_TOKEN']; + $this->assertNotEquals($this->testToken, $encryptedValue); + + // Step 2: Try to load with key 2 (wrong key) + $key2 = base64_encode(random_bytes(32)); + + // Suppress expected warning + set_error_handler(function () {}, E_USER_WARNING); + + $fs2 = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $key2 + ]); + + restore_error_handler(); + + // Token should not be loaded + $this->assertNull($fs2->getAccessToken(), 'Token should not load with wrong key'); + + // Session should be cleared + $this->assertArrayNotHasKey('FS_ACCESS_TOKEN', $_SESSION, 'Session should be cleared after decryption failure'); + } + + public function testEncryptionFailureDoesNotStoreToken(): void + { + // This test verifies fail-secure behavior + // If encryption somehow fails, the token should NOT be stored in plaintext + + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + // Simulate OAuth response with valid encryption + $this->simulateOAuthResponse($fs, $this->testToken); + + // If encryption worked, token should be in session + $this->assertArrayHasKey('FS_ACCESS_TOKEN', $_SESSION); + + // Token should be encrypted (not plaintext) + $this->assertNotEquals($this->testToken, $_SESSION['FS_ACCESS_TOKEN'], 'Token should be encrypted, not plaintext'); + } + + // ======================================================================== + // Custom Session Variable Tests + // ======================================================================== + + public function testEncryptionWorksWithCustomSessionVariable(): void + { + $customVar = 'MY_CUSTOM_TOKEN_VAR'; + + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessionVariable' => $customVar, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + $this->simulateOAuthResponse($fs, $this->testToken); + + // Verify token is stored in custom variable + $this->assertArrayHasKey($customVar, $_SESSION, 'Token should be stored in custom session variable'); + $this->assertArrayNotHasKey('FS_ACCESS_TOKEN', $_SESSION, 'Token should not be in default variable'); + + // Verify token is encrypted + $this->assertEquals(2, substr_count($_SESSION[$customVar], ':'), 'Token should be encrypted'); + + // Clean up + unset($_SESSION[$customVar]); + } + + public function testPlaintextTokenWorksWithCustomSessionVariable(): void + { + $customVar = 'MY_CUSTOM_TOKEN_VAR'; + + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessionVariable' => $customVar, + 'sessionEncryption' => false + ]); + + $this->simulateOAuthResponse($fs, $this->testToken); + + // Verify token is stored in custom variable as plaintext + $this->assertArrayHasKey($customVar, $_SESSION); + $this->assertEquals($this->testToken, $_SESSION[$customVar], 'Token should be plaintext in custom variable'); + + // Clean up + unset($_SESSION[$customVar]); + } + + // ======================================================================== + // Multiple Instance Tests + // ======================================================================== + + public function testMultipleInstancesShareEncryptedSession(): void + { + $config = [ + 'appKey' => 'test', + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]; + + // Instance 1: Store token + $fs1 = new FamilySearch($config); + $this->simulateOAuthResponse($fs1, $this->testToken); + + // Instance 2: Load token + $fs2 = new FamilySearch($config); + $this->assertEquals($this->testToken, $fs2->getAccessToken(), 'Second instance should load encrypted token'); + + // Instance 3: Also load token + $fs3 = new FamilySearch($config); + $this->assertEquals($this->testToken, $fs3->getAccessToken(), 'Third instance should also load encrypted token'); + } + + public function testDifferentKeysProduceDifferentCiphertexts(): void + { + $key1 = base64_encode(random_bytes(32)); + $key2 = base64_encode(random_bytes(32)); + + // Encrypt with key 1 + $fs1 = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $key1, + 'sessionVariable' => 'TOKEN1' + ]); + $this->simulateOAuthResponse($fs1, $this->testToken); + $encrypted1 = $_SESSION['TOKEN1']; + + // Encrypt with key 2 + $fs2 = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $key2, + 'sessionVariable' => 'TOKEN2' + ]); + $this->simulateOAuthResponse($fs2, $this->testToken); + $encrypted2 = $_SESSION['TOKEN2']; + + // Ciphertexts should be different + $this->assertNotEquals($encrypted1, $encrypted2, 'Different keys should produce different ciphertexts'); + + // Clean up + unset($_SESSION['TOKEN1']); + unset($_SESSION['TOKEN2']); + } + + // ======================================================================== + // Session Disabled Tests + // ======================================================================== + + public function testEncryptionDoesNotRunWhenSessionsDisabled(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + $this->simulateOAuthResponse($fs, $this->testToken); + + // Token should be in memory but not in session + $this->assertEquals($this->testToken, $fs->getAccessToken(), 'Token should be in memory'); + $this->assertArrayNotHasKey('FS_ACCESS_TOKEN', $_SESSION, 'Token should not be stored when sessions disabled'); + } + + public function testTokenNotPersistedWhenSessionsDisabled(): void + { + // First instance with sessions disabled + $fs1 = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false + ]); + $this->simulateOAuthResponse($fs1, $this->testToken); + + // Token available in first instance + $this->assertEquals($this->testToken, $fs1->getAccessToken()); + + // Second instance should not have token + $fs2 = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false + ]); + + $this->assertNull($fs2->getAccessToken(), 'Token should not persist when sessions disabled'); + } + + // ======================================================================== + // Manual Token Override Tests + // ======================================================================== + + public function testManualAccessTokenOverridesEncryptedSession(): void + { + // Store encrypted token in session + $fs1 = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + $this->simulateOAuthResponse($fs1, $this->testToken); + + // Create new instance with manual token override + $manualToken = 'manual-override-token'; + $fs2 = new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey, + 'accessToken' => $manualToken + ]); + + $this->assertEquals($manualToken, $fs2->getAccessToken(), 'Manual token should override session token'); + } +} diff --git a/tests/Unit/SessionEncryptionTest.php b/tests/Unit/SessionEncryptionTest.php new file mode 100644 index 0000000..9cd34c7 --- /dev/null +++ b/tests/Unit/SessionEncryptionTest.php @@ -0,0 +1,537 @@ +testKey = base64_encode(random_bytes(32)); + $this->testToken = 'test-access-token-' . bin2hex(random_bytes(8)); + + // Clear any existing session data + if (session_status() === PHP_SESSION_ACTIVE) { + session_destroy(); + } + if (isset($_SESSION)) { + unset($_SESSION); + } + } + + protected function tearDown(): void + { + // Clean up session data after each test + if (isset($_SESSION['FS_ACCESS_TOKEN'])) { + unset($_SESSION['FS_ACCESS_TOKEN']); + } + } + + /** + * Helper method to invoke private methods via reflection + */ + private function invokePrivateMethod(FamilySearch $fs, string $methodName, ...$args) + { + $reflection = new ReflectionClass($fs); + $method = $reflection->getMethod($methodName); + $method->setAccessible(true); + return $method->invoke($fs, ...$args); + } + + /** + * Helper method to get private property value via reflection + */ + private function getPrivateProperty(FamilySearch $fs, string $propertyName) + { + $reflection = new ReflectionClass($fs); + $property = $reflection->getProperty($propertyName); + $property->setAccessible(true); + return $property->getValue($fs); + } + + // ======================================================================== + // Configuration Tests + // ======================================================================== + + public function testEncryptionDisabledByDefault(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false + ]); + + $sessionEncryption = $this->getPrivateProperty($fs, 'sessionEncryption'); + $this->assertFalse($sessionEncryption, 'Encryption should be disabled by default'); + } + + public function testEncryptionCanBeEnabled(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + $sessionEncryption = $this->getPrivateProperty($fs, 'sessionEncryption'); + $this->assertTrue($sessionEncryption, 'Encryption should be enabled when configured'); + } + + public function testMissingKeyThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('sessionEncryptionKey is required'); + + new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => true + // Missing sessionEncryptionKey + ]); + } + + public function testEmptyKeyThrowsException(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('sessionEncryptionKey is required'); + + new FamilySearch([ + 'appKey' => 'test', + 'sessionEncryption' => true, + 'sessionEncryptionKey' => '' + ]); + } + + // ======================================================================== + // Key Validation Tests + // ======================================================================== + + public function testValid32ByteRawKey(): void + { + $rawKey = random_bytes(32); + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $rawKey + ]); + + $normalizedKey = $this->getPrivateProperty($fs, 'sessionEncryptionKey'); + $this->assertEquals(32, strlen($normalizedKey), 'Raw 32-byte key should be used as-is'); + $this->assertEquals($rawKey, $normalizedKey, 'Raw key should not be modified'); + } + + public function testValid44CharacterBase64Key(): void + { + $rawKey = random_bytes(32); + $base64Key = base64_encode($rawKey); + + $this->assertEquals(44, strlen($base64Key), 'Base64-encoded 32-byte key should be 44 characters'); + + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $base64Key + ]); + + $normalizedKey = $this->getPrivateProperty($fs, 'sessionEncryptionKey'); + $this->assertEquals(32, strlen($normalizedKey), 'Base64 key should be decoded to 32 bytes'); + $this->assertEquals($rawKey, $normalizedKey, 'Base64 key should decode to original raw key'); + } + + public function testValid64CharacterHexKey(): void + { + $rawKey = random_bytes(32); + $hexKey = bin2hex($rawKey); + + $this->assertEquals(64, strlen($hexKey), 'Hex-encoded 32-byte key should be 64 characters'); + + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $hexKey + ]); + + $normalizedKey = $this->getPrivateProperty($fs, 'sessionEncryptionKey'); + $this->assertEquals(32, strlen($normalizedKey), 'Hex key should be decoded to 32 bytes'); + $this->assertEquals($rawKey, $normalizedKey, 'Hex key should decode to original raw key'); + } + + public function testPassphraseDerivedToKey(): void + { + // Use a passphrase that's NOT exactly 32 bytes (to test hash derivation) + $passphrase = 'my-secure-passphrase-for-testing-that-is-longer'; + + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $passphrase + ]); + + $normalizedKey = $this->getPrivateProperty($fs, 'sessionEncryptionKey'); + $this->assertEquals(32, strlen($normalizedKey), 'Passphrase should be hashed to 32 bytes'); + + // Verify it matches SHA-256 hash + $expectedKey = hash('sha256', $passphrase, true); + $this->assertEquals($expectedKey, $normalizedKey, 'Passphrase should be derived using SHA-256'); + } + + public function testShortPassphraseDerivedToKey(): void + { + $shortPassphrase = 'short'; + + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $shortPassphrase + ]); + + $normalizedKey = $this->getPrivateProperty($fs, 'sessionEncryptionKey'); + $this->assertEquals(32, strlen($normalizedKey), 'Short passphrase should be hashed to 32 bytes'); + } + + // ======================================================================== + // Encryption/Decryption Tests + // ======================================================================== + + public function testEncryptionDecryptionRoundTrip(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + $encrypted = $this->invokePrivateMethod($fs, 'encryptToken', $this->testToken); + $decrypted = $this->invokePrivateMethod($fs, 'decryptToken', $encrypted); + + $this->assertEquals($this->testToken, $decrypted, 'Decrypted token should match original'); + } + + public function testEncryptedTokenFormat(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + $encrypted = $this->invokePrivateMethod($fs, 'encryptToken', $this->testToken); + + // Encrypted format should be: base64(iv):base64(tag):base64(ciphertext) + $this->assertIsString($encrypted); + $this->assertEquals(2, substr_count($encrypted, ':'), 'Encrypted token should have exactly 2 colons'); + + $parts = explode(':', $encrypted); + $this->assertCount(3, $parts, 'Encrypted token should have 3 segments'); + + // Each part should be valid base64 + foreach ($parts as $part) { + $this->assertNotEmpty($part, 'Each segment should be non-empty'); + $decoded = base64_decode($part, true); + $this->assertNotFalse($decoded, 'Each segment should be valid base64'); + } + } + + public function testEncryptedTokensDiffer(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + // Encrypt the same token twice + $encrypted1 = $this->invokePrivateMethod($fs, 'encryptToken', $this->testToken); + $encrypted2 = $this->invokePrivateMethod($fs, 'encryptToken', $this->testToken); + + $this->assertNotEquals($encrypted1, $encrypted2, 'Same token should produce different ciphertexts (IV uniqueness)'); + + // But both should decrypt to the same value + $decrypted1 = $this->invokePrivateMethod($fs, 'decryptToken', $encrypted1); + $decrypted2 = $this->invokePrivateMethod($fs, 'decryptToken', $encrypted2); + + $this->assertEquals($this->testToken, $decrypted1); + $this->assertEquals($this->testToken, $decrypted2); + } + + public function testIVUniqueness(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + $ivs = []; + for ($i = 0; $i < 10; $i++) { + $encrypted = $this->invokePrivateMethod($fs, 'encryptToken', $this->testToken); + $parts = explode(':', $encrypted); + $iv = $parts[0]; // First segment is the IV + $ivs[] = $iv; + } + + // All IVs should be unique + $uniqueIvs = array_unique($ivs); + $this->assertCount(10, $uniqueIvs, 'All generated IVs should be unique'); + } + + public function testDecryptionWithWrongKeyFails(): void + { + $fs1 = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + $encrypted = $this->invokePrivateMethod($fs1, 'encryptToken', $this->testToken); + + // Try to decrypt with wrong key + $wrongKey = base64_encode(random_bytes(32)); + $fs2 = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $wrongKey + ]); + + $decrypted = $this->invokePrivateMethod($fs2, 'decryptToken', $encrypted); + + $this->assertFalse($decrypted, 'Decryption with wrong key should fail'); + } + + public function testDecryptionWithCorruptedDataFails(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + $encrypted = $this->invokePrivateMethod($fs, 'encryptToken', $this->testToken); + + // Corrupt the ciphertext + $parts = explode(':', $encrypted); + $parts[2] = base64_encode('corrupted-data'); + $corrupted = implode(':', $parts); + + $decrypted = $this->invokePrivateMethod($fs, 'decryptToken', $corrupted); + + $this->assertFalse($decrypted, 'Decryption with corrupted data should fail'); + } + + public function testDecryptionWithTamperedAuthTagFails(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + $encrypted = $this->invokePrivateMethod($fs, 'encryptToken', $this->testToken); + + // Tamper with the authentication tag + $parts = explode(':', $encrypted); + $parts[1] = base64_encode(random_bytes(16)); // Replace tag with random data + $tampered = implode(':', $parts); + + $decrypted = $this->invokePrivateMethod($fs, 'decryptToken', $tampered); + + $this->assertFalse($decrypted, 'Decryption with tampered auth tag should fail'); + } + + public function testDecryptionWithInvalidFormatFails(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + // Test various invalid formats + $invalidFormats = [ + 'no-colons', + 'one:colon', + 'too:many:colons:here', + ':empty:first', + 'empty::middle', + 'empty:last:', + '', + 'invalid-base64!:invalid:data' + ]; + + foreach ($invalidFormats as $invalid) { + $decrypted = $this->invokePrivateMethod($fs, 'decryptToken', $invalid); + $this->assertFalse($decrypted, "Decryption should fail for format: $invalid"); + } + } + + // ======================================================================== + // Encryption Detection Tests + // ======================================================================== + + public function testIsEncryptedTokenDetectsEncryptedFormat(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + $encrypted = $this->invokePrivateMethod($fs, 'encryptToken', $this->testToken); + $isEncrypted = $this->invokePrivateMethod($fs, 'isEncryptedToken', $encrypted); + + $this->assertTrue($isEncrypted, 'Encrypted token should be detected as encrypted'); + } + + public function testIsEncryptedTokenDetectsPlaintextFormat(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false + ]); + + $plaintext = 'plain-access-token-123'; + $isEncrypted = $this->invokePrivateMethod($fs, 'isEncryptedToken', $plaintext); + + $this->assertFalse($isEncrypted, 'Plaintext token should not be detected as encrypted'); + } + + public function testIsEncryptedTokenHandlesEdgeCases(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false + ]); + + // Test edge cases - each test case is [value, expected] + $edgeCases = [ + ['', false], + ['no-colons', false], + ['one:colon', false], + ['two:colons:here', true], // Matches format (even if not valid base64) + ['three:colons:are:too:many', false], + [null, false], + [[], false], + [123, false], + ]; + + foreach ($edgeCases as list($value, $expected)) { + $isEncrypted = $this->invokePrivateMethod($fs, 'isEncryptedToken', $value); + $this->assertEquals($expected, $isEncrypted, "isEncryptedToken should return " . ($expected ? 'true' : 'false') . " for: " . var_export($value, true)); + } + } + + // ======================================================================== + // Error Handling Tests + // ======================================================================== + + public function testEncryptionWithoutOpenSSLThrowsException(): void + { + // This test can only run if OpenSSL is available (which it should be) + // We're testing that the check exists, not that it actually fails + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + // If we get here, OpenSSL is available (which is expected) + $this->assertTrue(extension_loaded('openssl'), 'OpenSSL should be available for testing'); + } + + public function testLongTokenCanBeEncryptedAndDecrypted(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + // Test with a long token (typical OAuth tokens can be 500+ characters) + $longToken = str_repeat('a', 1000); + + $encrypted = $this->invokePrivateMethod($fs, 'encryptToken', $longToken); + $decrypted = $this->invokePrivateMethod($fs, 'decryptToken', $encrypted); + + $this->assertEquals($longToken, $decrypted, 'Long token should encrypt and decrypt correctly'); + } + + public function testEmptyTokenCanBeEncryptedAndDecrypted(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + $emptyToken = ''; + + $encrypted = $this->invokePrivateMethod($fs, 'encryptToken', $emptyToken); + $decrypted = $this->invokePrivateMethod($fs, 'decryptToken', $encrypted); + + $this->assertEquals($emptyToken, $decrypted, 'Empty token should encrypt and decrypt correctly'); + } + + public function testSpecialCharactersInTokenArePreserved(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + $specialToken = "token-with-special-chars: !@#$%^&*()_+-=[]{}|;':\",./<>?`~\n\t\r"; + + $encrypted = $this->invokePrivateMethod($fs, 'encryptToken', $specialToken); + $decrypted = $this->invokePrivateMethod($fs, 'decryptToken', $encrypted); + + $this->assertEquals($specialToken, $decrypted, 'Special characters should be preserved'); + } + + public function testMultibyteCharactersInTokenArePreserved(): void + { + $fs = new FamilySearch([ + 'appKey' => 'test', + 'sessions' => false, + 'sessionEncryption' => true, + 'sessionEncryptionKey' => $this->testKey + ]); + + $multibyteToken = "token-with-unicode-こんにちは-🔐-émojis"; + + $encrypted = $this->invokePrivateMethod($fs, 'encryptToken', $multibyteToken); + $decrypted = $this->invokePrivateMethod($fs, 'decryptToken', $encrypted); + + $this->assertEquals($multibyteToken, $decrypted, 'Multibyte characters should be preserved'); + } +}