picoclaw/pkg/credential/keygen_test.go

116 lines
3 KiB
Go
Raw Normal View History

feat(credential): part1 add AES-GCM encryption, SecureStore, and onboard ke… (#1521) * 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
2026-03-16 06:06:32 +00:00
package credential
import (
"crypto/ed25519"
"os"
"path/filepath"
"runtime"
"testing"
"golang.org/x/crypto/ssh"
)
func TestGenerateSSHKey_CreatesFiles(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "test_ed25519.key")
if err := GenerateSSHKey(keyPath); err != nil {
t.Fatalf("GenerateSSHKey() error = %v", err)
}
// Private key must exist.
privInfo, err := os.Stat(keyPath)
if err != nil {
t.Fatalf("private key file missing: %v", err)
}
// Check permissions on non-Windows (Windows does not support Unix permission bits).
if runtime.GOOS != "windows" {
if got := privInfo.Mode().Perm(); got != 0o600 {
t.Errorf("private key permissions = %04o, want 0600", got)
}
}
// Public key must exist.
pubPath := keyPath + ".pub"
pubInfo, err := os.Stat(pubPath)
if err != nil {
t.Fatalf("public key file missing: %v", err)
}
if runtime.GOOS != "windows" {
if got := pubInfo.Mode().Perm(); got != 0o644 {
t.Errorf("public key permissions = %04o, want 0644", got)
}
}
// Private key must be parseable as an OpenSSH ed25519 key.
privPEM, err := os.ReadFile(keyPath)
if err != nil {
t.Fatalf("read private key: %v", err)
}
privKey, err := ssh.ParseRawPrivateKey(privPEM)
if err != nil {
t.Fatalf("parse private key: %v", err)
}
if _, ok := privKey.(*ed25519.PrivateKey); !ok {
t.Errorf("private key type = %T, want *ed25519.PrivateKey", privKey)
}
// Public key must be parseable as authorized_keys line.
pubBytes, err := os.ReadFile(pubPath)
if err != nil {
t.Fatalf("read public key: %v", err)
}
pubKey, _, _, rest, err := ssh.ParseAuthorizedKey(pubBytes)
if err != nil {
t.Fatalf("parse public key: %v", err)
}
if pubKey == nil {
t.Fatal("expected non-nil public key")
}
if len(rest) > 0 {
t.Errorf("unexpected trailing bytes after public key: %d bytes", len(rest))
}
}
func TestGenerateSSHKey_OverwritesExisting(t *testing.T) {
dir := t.TempDir()
keyPath := filepath.Join(dir, "test_ed25519.key")
// Generate twice; second call must not error and must produce a different key.
if err := GenerateSSHKey(keyPath); err != nil {
t.Fatalf("first GenerateSSHKey() error = %v", err)
}
first, err := os.ReadFile(keyPath)
if err != nil {
t.Fatalf("read first key: %v", err)
}
if err = GenerateSSHKey(keyPath); err != nil {
t.Fatalf("second GenerateSSHKey() error = %v", err)
}
second, err := os.ReadFile(keyPath)
if err != nil {
t.Fatalf("read second key: %v", err)
}
// Two independently generated Ed25519 keys must differ.
if string(first) == string(second) {
t.Error("expected overwritten key to differ from original")
}
}
func TestGenerateSSHKey_CreatesDirectory(t *testing.T) {
dir := t.TempDir()
// Nested directory that does not yet exist.
keyPath := filepath.Join(dir, "subdir", ".ssh", "picoclaw_ed25519.key")
if err := GenerateSSHKey(keyPath); err != nil {
t.Fatalf("GenerateSSHKey() error = %v", err)
}
if _, err := os.Stat(keyPath); err != nil {
t.Fatalf("private key not created: %v", err)
}
}