Files
picoclaw/pkg/credential/credential_test.go
T
sky5454 2f10b47f59 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 14:06:32 +08:00

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")
}
}