fix(credential): require SSH private key for encryption/decryption, remove passphrase-only mode
This commit is contained in:
parent
4fdff3e5c9
commit
82c17b4134
2 changed files with 46 additions and 35 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue