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.) // - Empty: "" → returned as-is (auth_method=oauth etc.)
// //
// Encryption uses AES-256-GCM with HKDF-SHA256 key derivation (< 1ms, safe for embedded Linux). // 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: // Key derivation:
// //
// With SSH key: HKDF-SHA256(ikm=HMAC-SHA256(SHA256(sshKeyBytes), passphrase), salt, info) // HKDF-SHA256(ikm=HMAC-SHA256(SHA256(sshKeyBytes), passphrase), salt, info)
// Without: HKDF-SHA256(ikm=SHA256(passphrase), salt, info)
// //
// SSH key path resolution priority: // SSH key path resolution priority:
// //
// 1. sshKeyPath argument to Encrypt (explicit) // 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) // 3. ~/.ssh/picoclaw_ed25519.key (os.UserHomeDir is cross-platform)
package credential package credential
@ -190,9 +190,9 @@ func resolveEncrypted(raw string) (string, error) {
// Encrypt encrypts plaintext and returns an enc:// credential string. // Encrypt encrypts plaintext and returns an enc:// credential string.
// //
// passphrase is required (PICOCLAW_KEY_PASSPHRASE value). // passphrase is required (PICOCLAW_KEY_PASSPHRASE value).
// sshKeyPath is the SSH private key file to incorporate; pass "" to use // sshKeyPath is the SSH private key file to use; pass "" to auto-detect via
// PICOCLAW_SSH_KEY_PATH env var or ~/.ssh/ auto-detection, or set // PICOCLAW_SSH_KEY_PATH env var or ~/.ssh/picoclaw_ed25519.key.
// PICOCLAW_SSH_KEY_PATH="" before calling to force passphrase-only mode. // An SSH private key must be resolvable or Encrypt returns an error.
func Encrypt(passphrase, sshKeyPath, plaintext string) (string, error) { func Encrypt(passphrase, sshKeyPath, plaintext string) (string, error) {
if passphrase == "" { if passphrase == "" {
return "", fmt.Errorf("credential: passphrase must not be empty") return "", fmt.Errorf("credential: passphrase must not be empty")
@ -271,29 +271,26 @@ func allowedSSHKeyPath(path string) bool {
return false 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) // ikm = HMAC-SHA256(key=SHA256(sshKeyBytes), msg=passphrase)
// Without: ikm = SHA256(passphrase) // Final key: HKDF-SHA256(ikm, salt, info="picoclaw-credential-v1", 32 bytes)
// 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) { func deriveKey(passphrase, sshKeyPath string, salt []byte) ([]byte, error) {
var ikm []byte if sshKeyPath == "" {
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)
} else {
h := sha256.Sum256([]byte(passphrase))
ikm = h[:]
} }
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) key, err := hkdf.Key(sha256.New, ikm, salt, hkdfInfo, keyLen)
if err != nil { if err != nil {
@ -306,11 +303,10 @@ func deriveKey(passphrase, sshKeyPath string, salt []byte) ([]byte, error) {
// //
// Priority: // Priority:
// 1. override (non-empty explicit argument) // 1. override (non-empty explicit argument)
// 2. PICOCLAW_SSH_KEY_PATH env var — if the variable is set (even to ""), auto-detection // 2. PICOCLAW_SSH_KEY_PATH env var
// is skipped; set it to "" to force passphrase-only mode
// 3. ~/.ssh/picoclaw_ed25519.key (auto-detection) // 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 { func pickSSHKeyPath(override string) string {
if override != "" { if override != "" {
return 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. // TestResolve_EncKey_RoundTrip tests basic encryption/decryption round-trip with an SSH key.
// PICOCLAW_SSH_KEY_PATH is set to "" to disable auto-detection and force passphrase-only mode. func TestResolve_EncKey_RoundTrip(t *testing.T) {
func TestResolve_EncKey_PassphraseOnly(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 passphrase = "test-passphrase-32bytes-long-ok!"
const plaintext = "sk-encrypted-secret" 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) enc, err := credential.Encrypt(passphrase, "", plaintext)
if err != nil { if err != nil {
@ -114,7 +119,12 @@ func TestResolve_EncKey_WithSSHKey(t *testing.T) {
} }
func TestResolve_EncKey_NoPassphrase(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") enc, err := credential.Encrypt("some-passphrase", "", "sk-secret")
if err != nil { if err != nil {
@ -155,7 +165,12 @@ func TestResolve_EncKey_PayloadTooShort(t *testing.T) {
} }
func TestResolve_EncKey_WrongPassphrase(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") enc, err := credential.Encrypt("correct-passphrase", "", "sk-secret")
if err != nil { if err != nil {