#21053: security(infra): OS keychain storage for device private keys
size: M
Cluster:
OpenClaw Plugin Enhancements
## Summary
- **Problem**: Ed25519 device private keys stored as plaintext JSON files (`~/.openclaw/identity/device.json`)
- **Impact**: MEDIUM severity (CVSS 5.0) — credential exposure risk on compromised or multi-tenant systems
- **Solution**: Platform-specific OS keychain integration with opt-in migration
## Change Type
- [ ] Bug fix
- [x] Feature
- [ ] Refactor
- [ ] Docs
- [x] Security hardening
- [ ] Chore/infra
## Scope
- [ ] Gateway / orchestration
- [ ] Skills / tool execution
- [x] Auth / tokens
- [x] Memory / storage
- [ ] Integrations
- [ ] API / contracts
- [ ] UI / DX
- [ ] CI/CD / infra
## Solution Details
### Platform Support
- **Linux**: libsecret via `secret-tool` CLI (GNOME Keyring, KWallet, etc.)
- **macOS**: Keychain Services via `security` CLI
- **Fallback**: Plaintext file storage when keychain unavailable or disabled
### Opt-in Activation
Set `OPENCLAW_KEYCHAIN=1` environment variable to enable keychain storage.
### Auto-Migration
When enabled, existing plaintext keys are automatically moved to the OS keychain on first load:
1. Read private key from keychain (if available)
2. If not in keychain but exists in file, migrate to keychain
3. Update JSON file to show `"privateKeyPem": "STORED_IN_KEYCHAIN"` placeholder
4. Original key removed from plaintext storage
### Files Changed
- `src/infra/keychain.ts` (NEW): `KeychainProvider` interface, `SecretToolKeychain` (Linux), `MacOSKeychain` (macOS), `resolveKeychainProvider()`, `storeInKeychain()`, `retrieveFromKeychain()`
- `src/infra/device-identity.ts`: Updated `loadOrCreateDeviceIdentity()` to read from keychain first, auto-migrate legacy files, store new keys in keychain if enabled
## Usage
### Linux (headless server)
```bash
# Start gnome-keyring daemon
eval $(gnome-keyring-daemon --start --components=secrets)
export DBUS_SESSION_BUS_ADDRESS
# Enable keychain storage
OPENCLAW_KEYCHAIN=1 openclaw gateway run
```
### macOS
```bash
# No additional setup needed — macOS Keychain always available
OPENCLAW_KEYCHAIN=1 openclaw gateway run
```
### Verification (Linux)
```bash
# Query libsecret for stored key
secret-tool lookup service openclaw key "device-private-key:/path/to/device.json"
# Verify plaintext file no longer contains key
cat ~/.openclaw/identity/device.json
# Expected: "privateKeyPem": "STORED_IN_KEYCHAIN"
```
### Verification (macOS)
```bash
# Query macOS Keychain
security find-generic-password -a openclaw -s "device-private-key:/path/to/device.json" -w
```
## Security Impact
- New permissions/capabilities? No
- Secrets/tokens handling changed? Yes — device private keys moved from plaintext file to OS keychain
- New/changed network calls? No
- Command/tool execution surface changed? No
- Data access scope changed? No
- **Benefit**: Reduces credential exposure risk by leveraging OS-level secure storage
## User-visible / Behavior Changes
- When `OPENCLAW_KEYCHAIN=1` is set, device private keys are stored in OS keychain instead of plaintext files
- Migration message logged on first run: `[keychain] Device private key migrated to OS keychain`
- No functional change to device authentication or gateway behavior
- Fully backward compatible: works with or without keychain enabled
## Compatibility / Migration
- Backward compatible? Yes — feature is opt-in via env var
- Config/env changes? Yes — set `OPENCLAW_KEYCHAIN=1` to enable
- Migration needed? No — auto-migrates on first launch when enabled
- Rollback: Remove env var and restart; reads from file as before
## Risks and Mitigations
- **Risk**: Keychain unavailable on headless Linux without `gnome-keyring-daemon`
- **Mitigation**: Feature is opt-in; falls back gracefully to file storage if keychain unavailable
- **Risk**: Key loss if keychain corrupted or user profile deleted
- **Mitigation**: Operators should backup keychain alongside other critical state; gateway regenerates device identity on next launch if key lost
- **Risk**: Additional dependency on `secret-tool` or `security` CLI
- **Mitigation**: Detection via `available()` check; clear error if enabled but CLI missing
## Evidence
- [x] Tested on Linux with gnome-keyring-daemon
- [x] Tested on macOS with Keychain Services
- [x] Migration verified: plaintext file updated after keychain storage
- [x] Backward compat verified: works with `OPENCLAW_KEYCHAIN=0` or unset
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
Implements OS keychain storage for device Ed25519 private keys to reduce plaintext credential exposure risk. When the keychain feature is enabled via environment variable, new keys are stored in the OS keychain (libsecret on Linux, Keychain Services on macOS) and existing keys are auto-migrated.
**Critical Issue Found:**
- After migration, the file contains `"privateKeyPem": "STORED_IN_KEYCHAIN"` placeholder. If keychain is later disabled, `loadOrCreateDeviceIdentity()` will return this placeholder string as the actual private key, causing `signDevicePayload()` to fail when `crypto.createPrivateKey("STORED_IN_KEYCHAIN")` throws. Need validation to detect this scenario and provide clear recovery instructions.
**Minor Issues:**
- Shell command construction in `SecretToolKeychain.store()` uses unescaped interpolation in the `--label` argument, which could break if file paths contain quotes or shell metacharacters.
**Strengths:**
- Good use of `JSON.stringify()` for escaping shell arguments in most places
- Proper opt-in design with graceful fallback
- Clear migration logging
<h3>Confidence Score: 2/5</h3>
- This PR contains a critical bug that will break authentication after disabling keychain post-migration
- Score reflects a critical backward compatibility issue where users who migrate to keychain and later disable it will be unable to authenticate (private key becomes the literal string "STORED_IN_KEYCHAIN"). The implementation is otherwise well-structured with proper shell escaping, but this bug is severe enough to block merging without a fix.
- Pay close attention to `src/infra/device-identity.ts` (migration logic needs validation for the placeholder string)
<sub>Last reviewed commit: efdf02b</sub>
<!-- greptile_other_comments_section -->
<sub>(5/5) You can turn off certain types of comments like style [here](https://app.greptile.com/review/github)!</sub>
<!-- /greptile_comment -->
Most Similar PRs
#7953: feat(security): encrypt credentials at rest with AES-256-GCM
by TGambit65 · 2026-02-03
78.4%
#10296: fix(ui): store Ed25519 private key as non-extractable CryptoKey in ...
by coygeek · 2026-02-06
76.9%
#8469: fix(auth): detect actual keychain account name when writing Claude ...
by adam-smeth · 2026-02-04
76.5%
#23574: security: P0 critical remediation — plugin sandbox, password hashin...
by lumeleopard001 · 2026-02-22
74.9%
#23165: fix(security): detect plaintext credentials in security audit
by ihsanmokhlisse · 2026-02-22
74.6%
#23586: Phase2 orchestrator
by Yaircohenh · 2026-02-22
74.1%
#6257: Fix: Create sensitive directories with mode 0o700
by sloppy-claw · 2026-02-01
73.3%
#21934: fix #21914 - Add the most obvious option to the error message
by vivganes · 2026-02-20
72.7%
#10514: Security: harden AGENTS.md with gateway, prompt injection, and supp...
by catpilothq · 2026-02-06
72.5%
#21055: security(cli): gate systemPromptReport behind --debug flag
by richvincent · 2026-02-19
72.4%