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
284 lines
8.3 KiB
Go
284 lines
8.3 KiB
Go
package credential_test
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
|
|
"github.com/sipeed/picoclaw/pkg/credential"
|
|
)
|
|
|
|
func TestResolve_PlainKey(t *testing.T) {
|
|
r := credential.NewResolver(t.TempDir())
|
|
got, err := r.Resolve("sk-plaintext-key")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != "sk-plaintext-key" {
|
|
t.Fatalf("got %q, want %q", got, "sk-plaintext-key")
|
|
}
|
|
}
|
|
|
|
func TestResolve_FileKey_Success(t *testing.T) {
|
|
dir := t.TempDir()
|
|
keyFile := "openai_plain.key"
|
|
if err := os.WriteFile(filepath.Join(dir, keyFile), []byte("sk-from-file\n"), 0o600); err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
|
|
r := credential.NewResolver(dir)
|
|
got, err := r.Resolve("file://" + keyFile)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != "sk-from-file" {
|
|
t.Fatalf("got %q, want %q", got, "sk-from-file")
|
|
}
|
|
}
|
|
|
|
func TestResolve_FileKey_NotFound(t *testing.T) {
|
|
r := credential.NewResolver(t.TempDir())
|
|
_, err := r.Resolve("file://missing.key")
|
|
if err == nil {
|
|
t.Fatal("expected error for missing file, got nil")
|
|
}
|
|
}
|
|
|
|
func TestResolve_FileKey_Empty(t *testing.T) {
|
|
dir := t.TempDir()
|
|
keyFile := "empty.key"
|
|
if err := os.WriteFile(filepath.Join(dir, keyFile), []byte(" \n"), 0o600); err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
|
|
r := credential.NewResolver(dir)
|
|
_, err := r.Resolve("file://" + keyFile)
|
|
if err == nil {
|
|
t.Fatal("expected error for empty credential file, got nil")
|
|
}
|
|
}
|
|
|
|
// 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", sshKeyPath)
|
|
|
|
enc, err := credential.Encrypt(passphrase, "", plaintext)
|
|
if err != nil {
|
|
t.Fatalf("Encrypt: %v", err)
|
|
}
|
|
|
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", passphrase)
|
|
|
|
r := credential.NewResolver(t.TempDir())
|
|
got, err := r.Resolve(enc)
|
|
if err != nil {
|
|
t.Fatalf("Resolve: %v", err)
|
|
}
|
|
if got != plaintext {
|
|
t.Fatalf("got %q, want %q", got, plaintext)
|
|
}
|
|
}
|
|
|
|
// TestResolve_EncKey_WithSSHKey tests that the SSH key file is incorporated into key derivation.
|
|
func TestResolve_EncKey_WithSSHKey(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
|
|
if err := os.WriteFile(sshKeyPath, []byte("fake-ssh-private-key-material\n"), 0o600); err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
|
|
const passphrase = "test-passphrase"
|
|
const plaintext = "sk-ssh-protected-secret"
|
|
|
|
// Set PICOCLAW_SSH_KEY_PATH before Encrypt so the path passes allowedSSHKeyPath validation.
|
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", passphrase)
|
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath)
|
|
|
|
enc, err := credential.Encrypt(passphrase, sshKeyPath, plaintext)
|
|
if err != nil {
|
|
t.Fatalf("Encrypt: %v", err)
|
|
}
|
|
|
|
r := credential.NewResolver(t.TempDir())
|
|
got, err := r.Resolve(enc)
|
|
if err != nil {
|
|
t.Fatalf("Resolve: %v", err)
|
|
}
|
|
if got != plaintext {
|
|
t.Fatalf("got %q, want %q", got, plaintext)
|
|
}
|
|
}
|
|
|
|
func TestResolve_EncKey_NoPassphrase(t *testing.T) {
|
|
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 {
|
|
t.Fatalf("Encrypt: %v", err)
|
|
}
|
|
|
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "")
|
|
|
|
r := credential.NewResolver(t.TempDir())
|
|
_, err = r.Resolve(enc)
|
|
if err == nil {
|
|
t.Fatal("expected error when PICOCLAW_KEY_PASSPHRASE is unset, got nil")
|
|
}
|
|
}
|
|
|
|
func TestResolve_EncKey_BadCiphertext(t *testing.T) {
|
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "some-passphrase")
|
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
|
|
|
r := credential.NewResolver(t.TempDir())
|
|
_, err := r.Resolve("enc://!!not-valid-base64!!")
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid enc:// payload, got nil")
|
|
}
|
|
}
|
|
|
|
func TestResolve_EncKey_PayloadTooShort(t *testing.T) {
|
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "some-passphrase")
|
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
|
|
|
// Valid base64 but fewer bytes than salt(16)+nonce(12)+1 minimum.
|
|
import64 := "dG9vc2hvcnQ=" // "tooshort" = 8 bytes
|
|
r := credential.NewResolver(t.TempDir())
|
|
_, err := r.Resolve("enc://" + import64)
|
|
if err == nil {
|
|
t.Fatal("expected error for too-short enc:// payload, got nil")
|
|
}
|
|
}
|
|
|
|
func TestResolve_EncKey_WrongPassphrase(t *testing.T) {
|
|
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 {
|
|
t.Fatalf("Encrypt: %v", err)
|
|
}
|
|
|
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "wrong-passphrase")
|
|
|
|
r := credential.NewResolver(t.TempDir())
|
|
_, err = r.Resolve(enc)
|
|
if err == nil {
|
|
t.Fatal("expected decryption error for wrong passphrase, got nil")
|
|
}
|
|
}
|
|
|
|
func TestEncrypt_EmptyPassphrase(t *testing.T) {
|
|
_, err := credential.Encrypt("", "", "sk-secret")
|
|
if err == nil {
|
|
t.Fatal("expected error for empty passphrase, got nil")
|
|
}
|
|
}
|
|
|
|
func TestDeriveKey_SSHKeyNotFound(t *testing.T) {
|
|
// Encrypt with a real SSH key path, then try to decrypt with a missing path.
|
|
dir := t.TempDir()
|
|
sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
|
|
if err := os.WriteFile(sshKeyPath, []byte("fake-key\n"), 0o600); err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
|
|
// Register the real key path so allowedSSHKeyPath validation passes for Encrypt.
|
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", sshKeyPath)
|
|
|
|
enc, err := credential.Encrypt("passphrase", sshKeyPath, "sk-secret")
|
|
if err != nil {
|
|
t.Fatalf("Encrypt: %v", err)
|
|
}
|
|
|
|
// Point to a non-existent SSH key so deriveKey's ReadFile fails.
|
|
// The path is still under the same dir, so allowedSSHKeyPath passes (exact env match).
|
|
t.Setenv("PICOCLAW_KEY_PASSPHRASE", "passphrase")
|
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", filepath.Join(dir, "nonexistent_key"))
|
|
|
|
r := credential.NewResolver(t.TempDir())
|
|
_, err = r.Resolve(enc)
|
|
if err == nil {
|
|
t.Fatal("expected error when SSH key file is missing, got nil")
|
|
}
|
|
}
|
|
|
|
// TestResolve_FileRef_PathTraversal verifies that file:// references cannot escape configDir
|
|
// via relative traversal ("../../etc/passwd") or absolute paths ("/abs/path").
|
|
func TestResolve_FileRef_PathTraversal(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.json")
|
|
// Create a file outside configDir that the traversal would point to.
|
|
outsideFile := filepath.Join(t.TempDir(), "secret.key")
|
|
if err := os.WriteFile(outsideFile, []byte("stolen"), 0o600); err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
|
|
r := credential.NewResolver(filepath.Dir(cfgPath))
|
|
|
|
cases := []string{
|
|
"file://../../secret.key",
|
|
"file://../secret.key",
|
|
"file://" + outsideFile, // absolute path
|
|
}
|
|
for _, raw := range cases {
|
|
_, err := r.Resolve(raw)
|
|
if err == nil {
|
|
t.Errorf("Resolve(%q): expected path traversal error, got nil", raw)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestResolve_FileRef_withinConfigDir verifies that a legitimate relative file:// ref works.
|
|
func TestResolve_FileRef_withinConfigDir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(dir, "my.key"), []byte("sk-valid\n"), 0o600); err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
r := credential.NewResolver(dir)
|
|
got, err := r.Resolve("file://my.key")
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if got != "sk-valid" {
|
|
t.Fatalf("got %q, want %q", got, "sk-valid")
|
|
}
|
|
}
|
|
|
|
// TestEncrypt_SSHKeyOutsideAllowedDirs verifies that Encrypt rejects SSH key paths
|
|
// that are not under PICOCLAW_SSH_KEY_PATH, PICOCLAW_HOME, or ~/.ssh/.
|
|
func TestEncrypt_SSHKeyOutsideAllowedDirs(t *testing.T) {
|
|
dir := t.TempDir()
|
|
sshKeyPath := filepath.Join(dir, "picoclaw_ed25519.key")
|
|
if err := os.WriteFile(sshKeyPath, []byte("fake-key\n"), 0o600); err != nil {
|
|
t.Fatalf("setup: %v", err)
|
|
}
|
|
|
|
// Make sure none of the allowed env vars point here.
|
|
t.Setenv("PICOCLAW_SSH_KEY_PATH", "")
|
|
t.Setenv("PICOCLAW_HOME", "")
|
|
|
|
_, err := credential.Encrypt("passphrase", sshKeyPath, "sk-secret")
|
|
if err == nil {
|
|
t.Fatal("expected error for SSH key outside allowed directories, got nil")
|
|
}
|
|
}
|