Skip to content

Commit ea98ce0

Browse files
committed
Add smart SOPS age key management to bootstrap
- Format age keys with public key comments - Append to existing key files instead of overwriting - Detect duplicate keys by comparing public keys - Preserve other existing keys in the file - Use age library's ParseIdentities() for proper parsing
1 parent 8e84485 commit ea98ce0

3 files changed

Lines changed: 285 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.6.7] - 2025-10-23
11+
12+
### Added
13+
- **Smart SOPS age key management** - Bootstrap now properly handles age keys:
14+
- Formats keys with public key comments (e.g., `# public key: age1...`)
15+
- Appends to existing key files instead of overwriting
16+
- Detects duplicate keys by comparing public keys (won't append the same key twice)
17+
- Preserves other existing keys in the file
18+
19+
### Changed
20+
- Bootstrap uses age library's `ParseIdentities()` for proper key file parsing
21+
1022
## [0.6.6] - 2025-10-23
1123

1224
### Added
@@ -162,7 +174,8 @@ bootstrap:
162174
- Support for Terraform and OpenTofu
163175
- CLI commands: plan, apply, destroy, list, output, clean
164176

165-
[Unreleased]: https://github.com/moonwalker/comet/compare/v0.6.6...HEAD
177+
[Unreleased]: https://github.com/moonwalker/comet/compare/v0.6.7...HEAD
178+
[0.6.7]: https://github.com/moonwalker/comet/releases/tag/v0.6.7
166179
[0.6.6]: https://github.com/moonwalker/comet/releases/tag/v0.6.6
167180
[0.6.5]: https://github.com/moonwalker/comet/releases/tag/v0.6.5
168181
[0.6.4]: https://github.com/moonwalker/comet/releases/tag/v0.6.4

internal/bootstrap/runner.go

Lines changed: 120 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@ import (
55
"os"
66
"os/exec"
77
"path/filepath"
8+
"regexp"
89
"runtime"
910
"strconv"
1011
"strings"
1112
"time"
1213

14+
"filippo.io/age"
1315
"github.com/moonwalker/comet/internal/log"
1416
"github.com/moonwalker/comet/internal/schema"
1517
"github.com/moonwalker/comet/internal/secrets"
@@ -184,17 +186,56 @@ func (r *Runner) runSecretStep(step *schema.BootstrapStep) error {
184186
mode = os.FileMode(modeInt)
185187
}
186188

189+
// Format the value based on the secret type
190+
var formattedValue string
191+
if isSopsAgeKeySource(step.Source) {
192+
// For SOPS age keys, format with public key comment
193+
formattedValue, err = formatAgeKey(value)
194+
if err != nil {
195+
log.Warn("Could not parse age key for formatting, saving as-is", "error", err)
196+
formattedValue = value
197+
}
198+
} else {
199+
formattedValue = value
200+
}
201+
187202
// Ensure value ends with newline (Unix text file convention)
188-
if len(value) > 0 && !strings.HasSuffix(value, "\n") {
189-
value = value + "\n"
203+
if len(formattedValue) > 0 && !strings.HasSuffix(formattedValue, "\n") {
204+
formattedValue = formattedValue + "\n"
190205
}
191206

192-
// Write file
193-
if err := os.WriteFile(targetPath, []byte(value), mode); err != nil {
194-
return fmt.Errorf("failed to write file: %w", err)
207+
// For SOPS age keys, check if the key already exists in the file
208+
if isSopsAgeKeySource(step.Source) {
209+
shouldAppend, err := shouldAppendAgeKey(targetPath, formattedValue)
210+
if err != nil {
211+
return fmt.Errorf("failed to check existing keys: %w", err)
212+
}
213+
if !shouldAppend {
214+
log.Info(fmt.Sprintf("ℹ️ Key already exists in: %s", targetPath))
215+
log.Info(fmt.Sprintf("✅ %s completed (%.2fs)", step.Name, duration.Seconds()))
216+
return nil
217+
}
218+
219+
// Append to existing file
220+
f, err := os.OpenFile(targetPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, mode)
221+
if err != nil {
222+
return fmt.Errorf("failed to open file for append: %w", err)
223+
}
224+
defer f.Close()
225+
226+
if _, err := f.WriteString(formattedValue); err != nil {
227+
return fmt.Errorf("failed to append to file: %w", err)
228+
}
229+
230+
log.Info(fmt.Sprintf("💾 Appended to: %s (mode: %o)", targetPath, mode))
231+
} else {
232+
// For non-age-key secrets, just write/overwrite
233+
if err := os.WriteFile(targetPath, []byte(formattedValue), mode); err != nil {
234+
return fmt.Errorf("failed to write file: %w", err)
235+
}
236+
log.Info(fmt.Sprintf("💾 Saved to: %s (mode: %o)", targetPath, mode))
195237
}
196238

197-
log.Info(fmt.Sprintf("💾 Saved to: %s (mode: %o)", targetPath, mode))
198239
log.Info(fmt.Sprintf("✅ %s completed (%.2fs)", step.Name, duration.Seconds()))
199240

200241
return nil
@@ -338,3 +379,76 @@ func NeedsBootstrap(config *schema.Config) bool {
338379

339380
return false
340381
}
382+
383+
// formatAgeKey formats an age secret key with a public key comment
384+
func formatAgeKey(secretKey string) (string, error) {
385+
// Remove whitespace
386+
secretKey = strings.TrimSpace(secretKey)
387+
388+
// Parse the age identity to get the public key
389+
identity, err := age.ParseX25519Identity(secretKey)
390+
if err != nil {
391+
return "", fmt.Errorf("failed to parse age identity: %w", err)
392+
}
393+
394+
// Get the recipient (public key)
395+
recipient := identity.Recipient()
396+
397+
// Format with public key comment
398+
formatted := fmt.Sprintf("# public key: %s\n%s", recipient.String(), secretKey)
399+
400+
return formatted, nil
401+
}
402+
403+
// shouldAppendAgeKey checks if an age key should be appended to the file
404+
// Returns true if the key doesn't exist, false if it already exists
405+
func shouldAppendAgeKey(filePath, formattedKey string) (bool, error) {
406+
// Parse the new key to get its public key
407+
newIdentity, err := age.ParseX25519Identity(strings.TrimSpace(extractSecretKey(formattedKey)))
408+
if err != nil {
409+
return false, fmt.Errorf("failed to parse new key: %w", err)
410+
}
411+
newPublicKey := newIdentity.Recipient().String()
412+
413+
// Check if file exists
414+
file, err := os.Open(filePath)
415+
if err != nil {
416+
if os.IsNotExist(err) {
417+
// File doesn't exist, we should create it
418+
return true, nil
419+
}
420+
return false, err
421+
}
422+
defer file.Close()
423+
424+
// Parse all existing identities from the file
425+
existingIdentities, err := age.ParseIdentities(file)
426+
if err != nil {
427+
// If file exists but has no valid keys (or is empty), we can append
428+
if strings.Contains(err.Error(), "no secret keys found") {
429+
return true, nil
430+
}
431+
return false, fmt.Errorf("failed to parse existing keys: %w", err)
432+
}
433+
434+
// Check if our public key already exists
435+
for _, identity := range existingIdentities {
436+
if x25519Identity, ok := identity.(*age.X25519Identity); ok {
437+
existingPublicKey := x25519Identity.Recipient().String()
438+
if existingPublicKey == newPublicKey {
439+
// Key already exists
440+
return false, nil
441+
}
442+
}
443+
}
444+
445+
// Key doesn't exist, we should append
446+
return true, nil
447+
}
448+
449+
// extractSecretKey extracts just the AGE-SECRET-KEY line from formatted content
450+
func extractSecretKey(content string) string {
451+
re := regexp.MustCompile(`(?m)^AGE-SECRET-KEY-[A-Z0-9]+$`)
452+
match := re.FindString(content)
453+
return match
454+
}

internal/bootstrap/runner_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import (
44
"os"
55
"path/filepath"
66
"runtime"
7+
"strings"
78
"testing"
9+
10+
"filippo.io/age"
811
)
912

1013
func TestExpandPath(t *testing.T) {
@@ -125,3 +128,151 @@ func getPlatformSopsPath(home string) string {
125128
}
126129
return filepath.Join(home, ".config", "sops", "age", "keys.txt")
127130
}
131+
132+
func TestFormatAgeKey(t *testing.T) {
133+
// Generate a real test age key
134+
identity, err := age.GenerateX25519Identity()
135+
if err != nil {
136+
t.Fatalf("Failed to generate test age key: %v", err)
137+
}
138+
testSecretKey := identity.String()
139+
140+
formatted, err := formatAgeKey(testSecretKey)
141+
if err != nil {
142+
t.Fatalf("formatAgeKey() error = %v", err)
143+
}
144+
145+
// Should contain the public key comment
146+
if !strings.Contains(formatted, "# public key: age1") {
147+
t.Errorf("formatAgeKey() should contain public key comment, got: %v", formatted)
148+
}
149+
150+
// Should contain the original secret key
151+
if !strings.Contains(formatted, testSecretKey) {
152+
t.Errorf("formatAgeKey() should contain secret key, got: %v", formatted)
153+
}
154+
155+
// Should have public key before secret key
156+
lines := strings.Split(formatted, "\n")
157+
if len(lines) < 2 {
158+
t.Errorf("formatAgeKey() should have at least 2 lines, got %d", len(lines))
159+
}
160+
if !strings.Contains(lines[0], "# public key:") {
161+
t.Errorf("formatAgeKey() first line should be public key comment, got: %v", lines[0])
162+
}
163+
if !strings.Contains(lines[1], "AGE-SECRET-KEY-") {
164+
t.Errorf("formatAgeKey() second line should be secret key, got: %v", lines[1])
165+
}
166+
}
167+
168+
func TestExtractSecretKey(t *testing.T) {
169+
tests := []struct {
170+
name string
171+
input string
172+
expected string
173+
}{
174+
{
175+
name: "extracts from formatted content",
176+
input: "# public key: age1abc123\nAGE-SECRET-KEY-1ABC123DEF456\n",
177+
expected: "AGE-SECRET-KEY-1ABC123DEF456",
178+
},
179+
{
180+
name: "extracts from plain key",
181+
input: "AGE-SECRET-KEY-1XYZ789\n",
182+
expected: "AGE-SECRET-KEY-1XYZ789",
183+
},
184+
{
185+
name: "returns empty for no match",
186+
input: "some random text\n",
187+
expected: "",
188+
},
189+
}
190+
191+
for _, tt := range tests {
192+
t.Run(tt.name, func(t *testing.T) {
193+
result := extractSecretKey(tt.input)
194+
if result != tt.expected {
195+
t.Errorf("extractSecretKey() = %v, want %v", result, tt.expected)
196+
}
197+
})
198+
}
199+
}
200+
201+
func TestShouldAppendAgeKey(t *testing.T) {
202+
// Generate test keys
203+
identity1, _ := age.GenerateX25519Identity()
204+
identity2, _ := age.GenerateX25519Identity()
205+
206+
formatted1, _ := formatAgeKey(identity1.String())
207+
formatted2, _ := formatAgeKey(identity2.String())
208+
209+
// Create temp directory for test files
210+
tmpDir := t.TempDir()
211+
testFile := filepath.Join(tmpDir, "test-keys.txt")
212+
213+
t.Run("should append to non-existent file", func(t *testing.T) {
214+
shouldAppend, err := shouldAppendAgeKey(testFile, formatted1)
215+
if err != nil {
216+
t.Fatalf("shouldAppendAgeKey() error = %v", err)
217+
}
218+
if !shouldAppend {
219+
t.Error("shouldAppendAgeKey() should return true for non-existent file")
220+
}
221+
})
222+
223+
t.Run("should append first key to empty file", func(t *testing.T) {
224+
// Create empty file
225+
os.WriteFile(testFile, []byte(""), 0600)
226+
227+
shouldAppend, err := shouldAppendAgeKey(testFile, formatted1)
228+
if err != nil {
229+
t.Fatalf("shouldAppendAgeKey() error = %v", err)
230+
}
231+
if !shouldAppend {
232+
t.Error("shouldAppendAgeKey() should return true for empty file")
233+
}
234+
})
235+
236+
t.Run("should not append duplicate key", func(t *testing.T) {
237+
// Write first key
238+
os.WriteFile(testFile, []byte(formatted1), 0600)
239+
240+
shouldAppend, err := shouldAppendAgeKey(testFile, formatted1)
241+
if err != nil {
242+
t.Fatalf("shouldAppendAgeKey() error = %v", err)
243+
}
244+
if shouldAppend {
245+
t.Error("shouldAppendAgeKey() should return false for duplicate key")
246+
}
247+
})
248+
249+
t.Run("should append different key", func(t *testing.T) {
250+
// File already has key1
251+
os.WriteFile(testFile, []byte(formatted1), 0600)
252+
253+
shouldAppend, err := shouldAppendAgeKey(testFile, formatted2)
254+
if err != nil {
255+
t.Fatalf("shouldAppendAgeKey() error = %v", err)
256+
}
257+
if !shouldAppend {
258+
t.Error("shouldAppendAgeKey() should return true for different key")
259+
}
260+
})
261+
262+
t.Run("should detect duplicate in multi-key file", func(t *testing.T) {
263+
// Write both keys
264+
content := formatted1 + "\n" + formatted2
265+
os.WriteFile(testFile, []byte(content), 0600)
266+
267+
// Try to add key1 again
268+
shouldAppend, err := shouldAppendAgeKey(testFile, formatted1)
269+
if err != nil {
270+
t.Fatalf("shouldAppendAgeKey() error = %v", err)
271+
}
272+
if shouldAppend {
273+
t.Error("shouldAppendAgeKey() should return false when key exists in multi-key file")
274+
}
275+
})
276+
}
277+
278+
// Helper functions are not needed - using strings package directly

0 commit comments

Comments
 (0)