fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode

This commit is contained in:
sky5454 2026-03-15 06:26:34 +08:00
parent 4fdff3e5c9
commit 82c17b4134
2 changed files with 46 additions and 35 deletions

View file

@ -12,15 +12,15 @@
// - Empty: "" → returned as-is (auth_method=oauth etc.)
//
// Encryption uses AES-256-GCM with HKDF-SHA256 key derivation (< 1ms, safe for embedded Linux).
// An SSH private key is required for both encryption and decryption.
// Key derivation:
//
// With SSH key: HKDF-SHA256(ikm=HMAC-SHA256(SHA256(sshKeyBytes), passphrase), salt, info)
// Without: HKDF-SHA256(ikm=SHA256(passphrase), salt, info)
// HKDF-SHA256(ikm=HMAC-SHA256(SHA256(sshKeyBytes), passphrase), salt, info)
//
// SSH key path resolution priority:
//
// 1. sshKeyPath argument to Encrypt (explicit)
// 2. PICOCLAW_SSH_KEY_PATH env var (set to "" to disable auto-detection)
// 2. PICOCLAW_SSH_KEY_PATH env var
// 3. ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform)
package credential
@ -190,9 +190,9 @@ func resolveEncrypted(raw string) (string, error) {
// Encrypt encrypts plaintext and returns an enc:// credential string.
//
// passphrase is required (PICOCLAW_KEY_PASSPHRASE value).
// sshKeyPath is the SSH private key file to incorporate; pass "" to use
// PICOCLAW_SSH_KEY_PATH env var or ~/.ssh/ auto-detection, or set
// PICOCLAW_SSH_KEY_PATH="" before calling to force passphrase-only mode.
// sshKeyPath is the SSH private key file to use; pass "" to auto-detect via
// PICOCLAW_SSH_KEY_PATH env var or ~/.ssh/picoclaw_ed25519.key.
// An SSH private key must be resolvable or Encrypt returns an error.
func Encrypt(passphrase, sshKeyPath, plaintext string) (string, error) {
if passphrase == "" {
return "", fmt.Errorf("credential: passphrase must not be empty")
@ -271,29 +271,26 @@ func allowedSSHKeyPath(path string) bool {
return false
}
// deriveKey derives a 32-byte AES-256 key from passphrase and optional SSH key.
// deriveKey derives a 32-byte AES-256 key from passphrase and SSH private key.
//
// With SSH key: ikm = HMAC-SHA256(key=SHA256(sshKeyBytes), msg=passphrase)
// Without: ikm = SHA256(passphrase)
// Final key: HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes)
// ikm = HMAC-SHA256(key=SHA256(sshKeyBytes), msg=passphrase)
// Final key: HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes)
// sshKeyPath must be non-empty; returns an error otherwise.
func deriveKey(passphrase, sshKeyPath string, salt []byte) ([]byte, error) {
var ikm []byte
if sshKeyPath != "" {
if !allowedSSHKeyPath(sshKeyPath) {
return nil, fmt.Errorf("credential: SSH key path %q is not in an allowed location (PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/)", sshKeyPath)
}
sshBytes, err := os.ReadFile(sshKeyPath)
if err != nil {
return nil, fmt.Errorf("credential: cannot read SSH key %q: %w", sshKeyPath, err)
}
sshHash := sha256.Sum256(sshBytes)
mac := hmac.New(sha256.New, sshHash[:])
mac.Write([]byte(passphrase))
ikm = mac.Sum(nil)
} else {
h := sha256.Sum256([]byte(passphrase))
ikm = h[:]
if sshKeyPath == "" {
return nil, fmt.Errorf("credential: SSH private key is required but not found (set PICOCLAW_SSH_KEY_PATH or place key at ~/.ssh/picoclaw_ed25519.key)")
}
if !allowedSSHKeyPath(sshKeyPath) {
return nil, fmt.Errorf("credential: SSH key path %q is not in an allowed location (PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/)", sshKeyPath)
}
sshBytes, err := os.ReadFile(sshKeyPath)
if err != nil {
return nil, fmt.Errorf("credential: cannot read SSH key %q: %w", sshKeyPath, err)
}
sshHash := sha256.Sum256(sshBytes)
mac := hmac.New(sha256.New, sshHash[:])
mac.Write([]byte(passphrase))
ikm := mac.Sum(nil)
key, err := hkdf.Key(sha256.New, ikm, salt, hkdfInfo, keyLen)
if err != nil {
@ -306,11 +303,10 @@ func deriveKey(passphrase, sshKeyPath string, salt []byte) ([]byte, error) {
//
// Priority:
// 1. override (non-empty explicit argument)
// 2. PICOCLAW_SSH_KEY_PATH env var — if the variable is set (even to ""), auto-detection
// is skipped; set it to "" to force passphrase-only mode
// 2. PICOCLAW_SSH_KEY_PATH env var
// 3. ~/.ssh/picoclaw_ed25519.key (auto-detection)
//
// Returns "" when no key is found (passphrase-only mode).
// Returns "" when no key is found; deriveKey will return an error in that case.
func pickSSHKeyPath(override string) string {
if override != "" {
return override

View file

@ -58,13 +58,18 @@ func TestResolve_FileKey_Empty(t *testing.T) {
}
}
// TestResolve_EncKey_PassphraseOnly tests encryption/decryption with passphrase alone.
// PICOCLAW_SSH_KEY_PATH is set to "" to disable auto-detection and force passphrase-only mode.
func TestResolve_EncKey_PassphraseOnly(t *testing.T) {
// TestResolve_EncKey_RoundTrip tests basic encryption/decryption round-trip with an SSH key.
func TestResolve_EncKey_RoundTrip(t *testing.T) {
dir := t.TempDir()
sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key-material\n"), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
const passphrase = "test-passphrase-32bytes-long-ok!"
const plaintext = "sk-encrypted-secret"
t.Setenv("PICOCLAW_SSH_KEY_PATH", "") // disable SSH key auto-detection
t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath)
enc, err := credential.Encrypt(passphrase, "", plaintext)
if err != nil {
@ -114,7 +119,12 @@ func TestResolve_EncKey_WithSSHKey(t *testing.T) {
}
func TestResolve_EncKey_NoPassphrase(t *testing.T) {
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
dir := t.TempDir()
sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key\n"), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath)
enc, err := credential.Encrypt("some-passphrase", "", "sk-secret")
if err != nil {
@ -155,7 +165,12 @@ func TestResolve_EncKey_PayloadTooShort(t *testing.T) {
}
func TestResolve_EncKey_WrongPassphrase(t *testing.T) {
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
dir := t.TempDir()
sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-key\n"), 0o600); err != nil {
t.Fatalf("setup: %v", err)
}
t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath)
enc, err := credential.Encrypt("correct-passphrase", "", "sk-secret")
if err != nil {