mirror of
https://github.com/sipeed/picoclaw.git
synced 2026-06-12 18:08:54 +00:00
2f10b47f59
* feat(credential): add AES-GCM encryption, SecureStore, and onboard keygen - pkg/credential: new package with AES-256-GCM enc:// credential format, HKDF-SHA256 key derivation (passphrase + optional SSH key binding), ErrPassphraseRequired / ErrDecryptionFailed sentinel errors, and PassphraseProvider hook for runtime passphrase injection - pkg/credential/store: lock-free SecureStore via atomic.Pointer[string]; passphrase never written to disk or os.Environ - pkg/credential/keygen: ed25519 SSH key generation helper used by onboard - pkg/config: replace os.Getenv(PassphraseEnvVar) with credential.PassphraseProvider() at all three call sites so that LoadConfig and SaveConfig use whatever passphrase source is active - cmd/picoclaw/onboard: prompt for passphrase with echo-off, generate picoclaw-specific SSH key, re-encrypt existing config on re-onboard - docs/credential_encryption.md: design doc for the enc:// format * fix(credential): address Copilot review comments on PR #1521 - credential.go: decouple ErrPassphraseRequired from env var name; message is now 'enc:// passphrase required' since PassphraseProvider may come from any source, not just os.Environ - credential.go: Resolver resolves symlinks via EvalSymlinks before the isWithinDir containment check, preventing symlink-based path traversal for file:// credential references - store.go: tighten comment to describe only what SecureStore guarantees (in-memory only); remove claims about how callers transport the value - store_test.go: replace the meaningless GetReturnsCopy test (Go strings are immutable, equality across two calls proves nothing) with TestSecureStore_ConcurrentSetGet that exercises atomic.Pointer under 10-goroutine concurrent Set/Get load - config_test.go: update error-message assertion to match new sentinel text - docs/credential_encryption.md: remove reference to non-existent 'picoclaw encrypt' subcommand; describe the onboard flow instead * fix(config): encryptPlaintextAPIKeys: struct-based encryption, fail-fast, remove raw []byte * fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode * lint: fix credential keygen lint, fix test keygen * onboard: make encryption opt-in via --enc flag Encryption (passphrase prompt + SSH key generation) is now only triggered when the user passes --enc to 'picoclaw onboard'. Without the flag, onboard skips the credential-encryption setup and writes a plain config + workspace templates directly. - Add --enc BoolFlag in NewOnboardCommand() - Pass encrypt bool into onboard() - Guard passphrase prompt, SSH key generation, and related env-var setup behind the encrypt branch - Adjust 'Next steps' output so the passphrase reminder only appears when --enc was used
169 lines
5.0 KiB
Markdown
169 lines
5.0 KiB
Markdown
# Credential Encryption
|
|
|
|
PicoClaw supports encrypting `api_key` values in `model_list` configuration entries.
|
|
Encrypted keys are stored as `enc://<base64>` strings and decrypted automatically at startup.
|
|
|
|
---
|
|
|
|
## Quick Start
|
|
|
|
**1. Set your passphrase**
|
|
|
|
```bash
|
|
export PICOCLAW_KEY_PASSPHRASE="your-passphrase"
|
|
```
|
|
|
|
**2. Encrypt an API key**
|
|
|
|
Run `picoclaw onboard` — it prompts for your passphrase and generates the SSH key,
|
|
then automatically re-encrypts any plaintext `api_key` entries in your config on
|
|
the next `SaveConfig` call. The resulting `enc://` value will look like:
|
|
|
|
```
|
|
enc://AAAA...base64...
|
|
```
|
|
|
|
**3. Paste the output into your config**
|
|
|
|
```json
|
|
{
|
|
"model_list": [
|
|
{
|
|
"model_name": "gpt-4o",
|
|
"api_key": "enc://AAAA...base64...",
|
|
"base_url": "https://api.openai.com/v1"
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## Supported `api_key` Formats
|
|
|
|
| Format | Example | Behaviour |
|
|
|--------|---------|-----------|
|
|
| Plaintext | `sk-abc123` | Used as-is |
|
|
| File reference | `file://openai.key` | Content read from the same directory as the config file |
|
|
| Encrypted | `enc://<base64>` | Decrypted at startup using `PICOCLAW_KEY_PASSPHRASE` |
|
|
| Empty | `""` | Passed through unchanged (used with `auth_method: oauth`) |
|
|
|
|
---
|
|
|
|
## Cryptographic Design
|
|
|
|
### Key Derivation
|
|
|
|
Encryption uses **HKDF-SHA256** with an optional SSH private key as a second factor.
|
|
|
|
```
|
|
Without SSH key (passphrase only):
|
|
|
|
ikm = SHA256(passphrase)
|
|
aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes)
|
|
|
|
|
|
With SSH key (recommended):
|
|
|
|
sshHash = SHA256(ssh_private_key_file_bytes)
|
|
ikm = HMAC-SHA256(key=sshHash, message=passphrase)
|
|
aes_key = HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes)
|
|
```
|
|
|
|
### Encryption
|
|
|
|
```
|
|
AES-256-GCM(key=aes_key, nonce=random[12], plaintext=api_key)
|
|
```
|
|
|
|
### Wire Format
|
|
|
|
```
|
|
enc://<base64( salt[16] + nonce[12] + ciphertext )>
|
|
```
|
|
|
|
| Field | Size | Description |
|
|
|-------|------|-------------|
|
|
| `salt` | 16 bytes | Random per encryption; fed into HKDF |
|
|
| `nonce` | 12 bytes | Random per encryption; AES-GCM IV |
|
|
| `ciphertext` | variable | AES-256-GCM ciphertext + 16-byte authentication tag |
|
|
|
|
The GCM authentication tag is appended to the ciphertext automatically. Any tampering causes decryption to fail with an error rather than returning corrupt plaintext.
|
|
|
|
### Performance
|
|
|
|
| Operation | Time (ARM Cortex-A) |
|
|
|-----------|---------------------|
|
|
| Key derivation (HKDF) | < 1 ms |
|
|
| AES-256-GCM decrypt | < 1 ms |
|
|
| **Total startup overhead** | **< 2 ms per key** |
|
|
|
|
---
|
|
|
|
## Two-Factor Security with SSH Key
|
|
|
|
When a SSH private key is provided, breaking the encryption requires **both**:
|
|
|
|
1. The **passphrase** (`PICOCLAW_KEY_PASSPHRASE`)
|
|
2. The **SSH private key file**
|
|
|
|
This means a leaked config file alone is not sufficient to recover the API key, even if the passphrase is weak. The SSH key contributes 256 bits of entropy (Ed25519) regardless of passphrase strength.
|
|
|
|
### Threat Model
|
|
|
|
| Attacker Has | Can Decrypt? |
|
|
|---|---|
|
|
| Config file only | No — needs passphrase + SSH key |
|
|
| SSH key only | No — needs passphrase |
|
|
| Passphrase only | No — needs SSH key |
|
|
| Config file + SSH key + passphrase | Yes — full compromise |
|
|
|
|
---
|
|
|
|
## Environment Variables
|
|
|
|
| Variable | Required | Description |
|
|
|----------|----------|-------------|
|
|
| `PICOCLAW_KEY_PASSPHRASE` | Yes (for `enc://`) | Passphrase used for key derivation |
|
|
| `PICOCLAW_SSH_KEY_PATH` | No | Path to SSH private key. Set to `""` to disable auto-detection and use passphrase-only mode |
|
|
|
|
### SSH Key Auto-Detection
|
|
|
|
If `PICOCLAW_SSH_KEY_PATH` is not set, PicoClaw looks for the picoclaw-specific key:
|
|
|
|
```
|
|
~/.ssh/picoclaw_ed25519.key
|
|
```
|
|
|
|
This dedicated file avoids conflicts with the user's existing SSH keys.
|
|
Run `picoclaw onboard` to generate it automatically.
|
|
|
|
`os.UserHomeDir()` is used for cross-platform home directory resolution (reads `USERPROFILE` on Windows, `HOME` on Unix/macOS).
|
|
|
|
To explicitly disable SSH key usage and use passphrase-only mode:
|
|
|
|
```bash
|
|
export PICOCLAW_SSH_KEY_PATH=""
|
|
```
|
|
|
|
---
|
|
|
|
## Migration
|
|
|
|
Because the only secret material is `PICOCLAW_KEY_PASSPHRASE` and the SSH private key file, migration is straightforward:
|
|
|
|
1. Copy the config file to the new machine.
|
|
2. Set `PICOCLAW_KEY_PASSPHRASE` to the same value.
|
|
3. Copy the SSH private key file to the same path (or set `PICOCLAW_SSH_KEY_PATH` to its new location).
|
|
|
|
No re-encryption is needed.
|
|
|
|
---
|
|
|
|
## Security Considerations
|
|
|
|
- **Passphrase strength matters in passphrase-only mode.** Without an SSH key, a weak passphrase can be brute-forced offline. Use `PICOCLAW_SSH_KEY_PATH=""` only in environments where no SSH key is available and the passphrase is sufficiently strong (≥ 32 random characters).
|
|
- **The SSH key is read-only at runtime.** PicoClaw never writes to or modifies the SSH key file.
|
|
- **Plaintext keys remain supported.** Existing configs without `enc://` are unaffected.
|
|
- **The `enc://` format is versioned** via the HKDF `info` field (`picoclaw-credential-v1`), allowing future algorithm upgrades without breaking existing encrypted values.
|