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
82 lines
1.6 KiB
Go
82 lines
1.6 KiB
Go
package credential
|
|
|
|
import (
|
|
"sync"
|
|
"testing"
|
|
)
|
|
|
|
func TestSecureStore_SetGet(t *testing.T) {
|
|
s := NewSecureStore()
|
|
if s.IsSet() {
|
|
t.Error("expected empty store")
|
|
}
|
|
|
|
s.SetString("hunter2")
|
|
if !s.IsSet() {
|
|
t.Error("expected store to be set")
|
|
}
|
|
if got := s.Get(); got != "hunter2" {
|
|
t.Errorf("Get() = %q, want %q", got, "hunter2")
|
|
}
|
|
}
|
|
|
|
func TestSecureStore_Clear(t *testing.T) {
|
|
s := NewSecureStore()
|
|
s.SetString("secret")
|
|
s.Clear()
|
|
|
|
if s.IsSet() {
|
|
t.Error("expected store to be empty after Clear()")
|
|
}
|
|
if got := s.Get(); got != "" {
|
|
t.Errorf("Get() after Clear() = %q, want empty", got)
|
|
}
|
|
}
|
|
|
|
func TestSecureStore_SetOverwrites(t *testing.T) {
|
|
s := NewSecureStore()
|
|
s.SetString("first")
|
|
s.SetString("second")
|
|
|
|
if got := s.Get(); got != "second" {
|
|
t.Errorf("Get() = %q, want %q", got, "second")
|
|
}
|
|
}
|
|
|
|
func TestSecureStore_EmptyPassphrase(t *testing.T) {
|
|
s := NewSecureStore()
|
|
s.SetString("") // empty → should not mark as set
|
|
|
|
if s.IsSet() {
|
|
t.Error("empty passphrase should not mark store as set")
|
|
}
|
|
}
|
|
|
|
func TestSecureStore_ConcurrentSetGet(t *testing.T) {
|
|
s := NewSecureStore()
|
|
const goroutines = 10
|
|
const iterations = 1000
|
|
|
|
var wg sync.WaitGroup
|
|
wg.Add(goroutines)
|
|
for i := 0; i < goroutines; i++ {
|
|
go func(id int) {
|
|
defer wg.Done()
|
|
for j := 0; j < iterations; j++ {
|
|
if id%2 == 0 {
|
|
s.SetString("even")
|
|
} else {
|
|
s.SetString("odd")
|
|
}
|
|
_ = s.Get()
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
|
|
final := s.Get()
|
|
if final != "" && final != "even" && final != "odd" {
|
|
t.Errorf("Get() returned unexpected value %q after concurrent Set/Get", final)
|
|
}
|
|
}
|