Skip to content

Commit bf8c2c1

Browse files
author
Gemini CLI
committed
feat: implement real secret management with age encryption
1 parent 57df24b commit bf8c2c1

File tree

2 files changed

+146
-44
lines changed

2 files changed

+146
-44
lines changed

cmd/aegisclaw/main.go

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ func secretsCmd() *cobra.Command {
236236
return err
237237
}
238238

239-
fmt.Printf("🔐 Secret '%s' encryption simulated (Phase 5 MVP)\n", key)
239+
fmt.Printf("🔐 Secret '%s' encrypted and saved.\n", key)
240240
return nil
241241
},
242242
})
@@ -245,9 +245,28 @@ func secretsCmd() *cobra.Command {
245245
Use: "list",
246246
Short: "List stored secrets (names only)",
247247
RunE: func(cmd *cobra.Command, args []string) error {
248+
cfgDir, err := config.DefaultConfigDir()
249+
if err != nil {
250+
return err
251+
}
252+
253+
secretsDir := filepath.Join(cfgDir, "secrets")
254+
mgr := secrets.NewManager(secretsDir)
255+
256+
keys, err := mgr.List()
257+
if err != nil {
258+
return err
259+
}
260+
261+
if len(keys) == 0 {
262+
fmt.Println("🔐 No secrets stored.")
263+
return nil
264+
}
265+
248266
fmt.Println("🔐 Stored Secrets:")
249-
// List logic would go here
250-
fmt.Println(" (listing not implemented in MVP)")
267+
for _, k := range keys {
268+
fmt.Printf(" • %s\n", k)
269+
}
251270
return nil
252271
},
253272
})

internal/secrets/secrets.go

Lines changed: 124 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
package secrets
22

33
import (
4+
"bytes"
45
"fmt"
6+
"io"
57
"os"
68
"path/filepath"
79

810
"filippo.io/age"
11+
"gopkg.in/yaml.v3"
912
)
1013

1114
// Manager handles secret encryption and storage
@@ -51,57 +54,137 @@ func (m *Manager) Init() (string, error) {
5154
return identity.Recipient().String(), nil
5255
}
5356

54-
// Store saves a secret value (simplified for MVP: just writing to a file)
55-
// In a real implementation, this would use SOPS to encrypt a YAML file.
56-
// For this MVP without the sops binary, we'll implement direct age encryption.
57+
// Set encrypts and stores a secret value
5758
func (m *Manager) Set(key, value string) error {
58-
// 1. Load recipient (public key)
59-
fKeys, err := os.Open(m.keyFile)
59+
secrets, err := m.loadAll()
60+
if err != nil && !os.IsNotExist(err) {
61+
return err
62+
}
63+
if secrets == nil {
64+
secrets = make(map[string]string)
65+
}
66+
67+
secrets[key] = value
68+
return m.saveAll(secrets)
69+
}
70+
71+
// Get retrieves and decrypts a specific secret
72+
func (m *Manager) Get(key string) (string, error) {
73+
secrets, err := m.loadAll()
6074
if err != nil {
61-
return fmt.Errorf("failed to open key file (did you run 'secrets init'?): %w", err)
75+
return "", err
6276
}
63-
defer fKeys.Close()
77+
val, ok := secrets[key]
78+
if !ok {
79+
return "", fmt.Errorf("secret '%s' not found", key)
80+
}
81+
return val, nil
82+
}
6483

65-
_, err = age.ParseIdentities(fKeys)
84+
// List returns the names of all stored secrets
85+
func (m *Manager) List() ([]string, error) {
86+
secrets, err := m.loadAll()
6687
if err != nil {
67-
return fmt.Errorf("failed to parse keys: %w", err)
68-
}
69-
70-
// We need the recipient, usually simpler to just parse the first line or store pubkey strictly.
71-
// For MVP, simplified approach:
72-
// We will assume the secrets file is a simple YAML map, encrypted as a blob.
73-
// Loading, decrypting, updating, and re-encrypting.
74-
75-
// For stricter MVP: just standard file for now but encrypted content?
76-
// Let's implement a dummy "Encrypted" marker for now as true SOPS integration logic requires external bins.
77-
88+
if os.IsNotExist(err) {
89+
return []string{}, nil
90+
}
91+
return nil, err
92+
}
93+
94+
keys := make([]string, 0, len(secrets))
95+
for k := range secrets {
96+
keys = append(keys, k)
97+
}
98+
return keys, nil
99+
}
100+
101+
func (m *Manager) loadAll() (map[string]string, error) {
78102
secretsPath := filepath.Join(m.configDir, "secrets.enc")
79-
80-
// Implementation note: Fully implementing age encryption in pure Go here is possible
81-
// but might be verbose for this step. Let's create the key infrastructure and
82-
// a placeholder for the actual encryption to keep it buildable.
83-
84-
// We'll write to a plaintext file for now, clearly marked, to demonstrate the flow,
85-
// or fail if we want to be strict.
86-
// BETTER: Let's assume we proceed with the structure but warn about encryption.
87-
88-
f, err := os.OpenFile(secretsPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600)
103+
if _, err := os.Stat(secretsPath); os.IsNotExist(err) {
104+
return nil, err
105+
}
106+
107+
// 1. Load identity
108+
identity, err := m.getIdentity()
89109
if err != nil {
90-
return err
110+
return nil, err
111+
}
112+
113+
// 2. Read encrypted file
114+
f, err := os.Open(secretsPath)
115+
if err != nil {
116+
return nil, err
91117
}
92118
defer f.Close()
93-
94-
// Mock encryption
95-
if _, err := fmt.Fprintf(f, "%s: [ENCRYPTED]%s\n", key, value); err != nil {
96-
return err
119+
120+
// 3. Decrypt
121+
r, err := age.Decrypt(f, identity)
122+
if err != nil {
123+
return nil, fmt.Errorf("failed to decrypt secrets: %w", err)
124+
}
125+
126+
data, err := io.ReadAll(r)
127+
if err != nil {
128+
return nil, err
97129
}
98-
99-
return nil
130+
131+
// 4. Parse YAML
132+
var secrets map[string]string
133+
if err := yaml.Unmarshal(data, &secrets); err != nil {
134+
return nil, err
135+
}
136+
137+
return secrets, nil
100138
}
101139

102-
// GetRecipient returns the public key for the managed identity
103-
func (m *Manager) GetRecipient() (string, error) {
104-
// Read first line which should be the private key, derive public
105-
// This is a simplification.
106-
return "age1...", nil
140+
func (m *Manager) saveAll(secrets map[string]string) error {
141+
secretsPath := filepath.Join(m.configDir, "secrets.enc")
142+
143+
// 1. Get recipient
144+
identity, err := m.getIdentity()
145+
if err != nil {
146+
return err
147+
}
148+
recipient := identity.Recipient()
149+
150+
// 2. Marshal secrets
151+
data, err := yaml.Marshal(secrets)
152+
if err != nil {
153+
return err
154+
}
155+
156+
// 3. Encrypt
157+
buf := &bytes.Buffer{}
158+
w, err := age.Encrypt(buf, recipient)
159+
if err != nil {
160+
return err
161+
}
162+
if _, err := w.Write(data); err != nil {
163+
return err
164+
}
165+
if err := w.Close(); err != nil {
166+
return err
167+
}
168+
169+
// 4. Write to disk
170+
return os.WriteFile(secretsPath, buf.Bytes(), 0600)
107171
}
172+
173+
func (m *Manager) getIdentity() (*age.X25519Identity, error) {
174+
data, err := os.ReadFile(m.keyFile)
175+
if err != nil {
176+
return nil, fmt.Errorf("failed to read key file (run 'secrets init'): %w", err)
177+
}
178+
179+
// Parse first non-comment line
180+
lines := bytes.Split(data, []byte("\n"))
181+
for _, line := range lines {
182+
line = bytes.TrimSpace(line)
183+
if len(line) == 0 || line[0] == '#' {
184+
continue
185+
}
186+
return age.ParseX25519Identity(string(line))
187+
}
188+
189+
return nil, fmt.Errorf("no identity found in key file")
190+
}

0 commit comments

Comments
 (0)