Skip to content

Commit 6e897a2

Browse files
feat(secrets,cli): S5 (part) — secrets keygen age keypair generator (spec 04) (#39)
The offline onboarding companion to the SOPS+age provider (S2): generate the local age identity SOPS_AGE_KEY_FILE points at, no account, fully in-process (filippo.io/age — pure Go, static-binary safe). - secrets.GenerateAgeKey() → {Identity AGE-SECRET-KEY-…, Recipient age1…} + AgeKeyFileContents() (the `# public key:` + secret body `age-keygen` writes). - CLI `secrets keygen [-o FILE]`: with -o writes the key 0600 (+ a SOPS_AGE_KEY_FILE export hint); without, prints the key to stdout and the public recipient to stderr (so a pipe captures only the key). --json supported. Replaces the keygen stub; `secrets login` (keyring) stays stubbed for the rest of S5. Unit tests: generated identity round-trips through age.ParseX25519Identity and yields the reported recipient; two calls differ; key-file body shape; CLI writes a 0600 file with the recipient comment + SOPS hint. Note: local govulncheck flags go1.26.0 stdlib advisories (fixed in go1.26.3+) unrelated to this change; CI runs Go 1.25.x-latest where they don't apply. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent d045fca commit 6e897a2

8 files changed

Lines changed: 202 additions & 4 deletions

File tree

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ go 1.25.0
66

77
require (
88
charm.land/fang/v2 v2.0.1
9+
filippo.io/age v1.3.1
910
github.com/adrg/xdg v0.5.3
1011
github.com/compose-spec/compose-go/v2 v2.12.1
1112
github.com/go-playground/validator/v10 v10.30.3
@@ -23,6 +24,7 @@ require (
2324

2425
require (
2526
charm.land/lipgloss/v2 v2.0.1 // indirect
27+
filippo.io/hpke v0.4.0 // indirect
2628
github.com/Microsoft/go-winio v0.6.2 // indirect
2729
github.com/charmbracelet/colorprofile v0.4.2 // indirect
2830
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
1+
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M=
2+
c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo=
13
charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY=
24
charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII=
35
charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys=
46
charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM=
7+
filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0=
8+
filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4=
9+
filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A=
10+
filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY=
511
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
612
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
713
github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=

internal/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ func NewRootCmd(opts Options) *cobra.Command {
8383
newDnsCmd(g),
8484
newTrustCmd(g),
8585
newTunnelCmd(g),
86+
newSecretsCmd(g),
8687
newDoctorCmd(g),
8788
newConfigCmd(g),
8889
newGenerateCmd(g),

internal/cli/secrets.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
8+
"github.com/spf13/cobra"
9+
10+
"github.com/open-source-cloud/devstack/internal/secrets"
11+
)
12+
13+
// newSecretsCmd wires `secrets keygen` (real) and `secrets login` (stub until the
14+
// keyring lands, S5). keygen generates the offline age identity that the SOPS+age
15+
// provider (S2) decrypts with — no account, fully local.
16+
func newSecretsCmd(g *GlobalOpts) *cobra.Command {
17+
cmd := &cobra.Command{
18+
Use: "secrets",
19+
Short: "Secrets providers (age keygen, provider login)",
20+
}
21+
cmd.AddCommand(
22+
newSecretsKeygenCmd(g),
23+
stub("login", "Authenticate a secrets provider (keyring) — S5", "M4"),
24+
)
25+
return cmd
26+
}
27+
28+
func newSecretsKeygenCmd(g *GlobalOpts) *cobra.Command {
29+
var output string
30+
cmd := &cobra.Command{
31+
Use: "keygen",
32+
Short: "Generate an age keypair for SOPS+age (offline, no account)",
33+
Args: cobra.NoArgs,
34+
RunE: func(cmd *cobra.Command, _ []string) error {
35+
k, err := secrets.GenerateAgeKey()
36+
if err != nil {
37+
return err
38+
}
39+
if output == "" {
40+
// No file: print the key body to stdout (caller redirects) and the
41+
// public recipient to stderr so a pipe captures only the key.
42+
if g.JSON {
43+
return writeJSON(cmd, map[string]string{"recipient": k.Recipient, "identity": k.Identity})
44+
}
45+
fmt.Fprint(cmd.OutOrStdout(), k.AgeKeyFileContents())
46+
fmt.Fprintf(cmd.ErrOrStderr(), "public recipient: %s\n", k.Recipient)
47+
return nil
48+
}
49+
if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil {
50+
return err
51+
}
52+
if err := os.WriteFile(output, []byte(k.AgeKeyFileContents()), 0o600); err != nil {
53+
return fmt.Errorf("write age key %s: %w", output, err)
54+
}
55+
if g.JSON {
56+
return writeJSON(cmd, map[string]string{"path": output, "recipient": k.Recipient})
57+
}
58+
fmt.Fprintf(cmd.OutOrStdout(), "wrote age key to %s\npublic recipient: %s\n", output, k.Recipient)
59+
fmt.Fprintf(cmd.OutOrStdout(), "→ point SOPS at it: export SOPS_AGE_KEY_FILE=%s\n", output)
60+
return nil
61+
},
62+
}
63+
cmd.Flags().StringVarP(&output, "output", "o", "", "write the key to this file (0600) instead of stdout")
64+
return cmd
65+
}

internal/cli/secrets_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
package cli
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"strings"
7+
"testing"
8+
)
9+
10+
func TestSecretsKeygenRegistered(t *testing.T) {
11+
if !findCmd(t, "secrets") {
12+
// secrets is a group; verify the keygen child is real.
13+
}
14+
root := NewRootCmd(Options{})
15+
c, _, err := root.Find([]string{"secrets", "keygen"})
16+
if err != nil || c.RunE == nil {
17+
t.Fatalf("secrets keygen not registered as a real command: %v", err)
18+
}
19+
}
20+
21+
func TestSecretsKeygenWritesFile(t *testing.T) {
22+
out := filepath.Join(t.TempDir(), "age", "keys.txt")
23+
root := NewRootCmd(Options{})
24+
var buf strings.Builder
25+
root.SetOut(&buf)
26+
root.SetErr(&buf)
27+
root.SetArgs([]string{"secrets", "keygen", "--output", out})
28+
if err := root.Execute(); err != nil {
29+
t.Fatalf("keygen: %v\n%s", err, buf.String())
30+
}
31+
data, err := os.ReadFile(out)
32+
if err != nil {
33+
t.Fatal(err)
34+
}
35+
body := string(data)
36+
if !strings.Contains(body, "AGE-SECRET-KEY-1") || !strings.Contains(body, "# public key: age1") {
37+
t.Errorf("key file = %q", body)
38+
}
39+
// 0600 perms on the secret.
40+
fi, _ := os.Stat(out)
41+
if fi.Mode().Perm() != 0o600 {
42+
t.Errorf("key file mode = %v, want 0600", fi.Mode().Perm())
43+
}
44+
if !strings.Contains(buf.String(), "SOPS_AGE_KEY_FILE") {
45+
t.Errorf("output should hint SOPS_AGE_KEY_FILE:\n%s", buf.String())
46+
}
47+
}

internal/cli/stubs.go

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,10 +32,6 @@ func addStubCommands(root *cobra.Command, _ *GlobalOpts) {
3232
root.AddCommand(
3333
stub("shell", "Open a shell in a service container", "M2"),
3434
stub("logs", "Stream service logs", "M2"),
35-
stub("secrets", "Secrets providers (login, keygen)", "M4",
36-
stub("login", "Authenticate a secrets provider", "M4"),
37-
stub("keygen", "Generate an age/SOPS key", "M4"),
38-
),
3935
stub("import", "Import an old devdock project.yaml into workspace.yaml + devstack.yaml", "M1"),
4036
stub("workspace", "Workspace-level lifecycle", "M6",
4137
stub("destroy", "Reverse ALL machine-global artifacts for this workspace", "M6"),

internal/secrets/keygen.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
package secrets
2+
3+
import (
4+
"fmt"
5+
6+
"filippo.io/age"
7+
)
8+
9+
// This file is the age keypair generator behind `secrets keygen` (spec 04) — the
10+
// onboarding companion to the SOPS+age provider (S2): it lets a developer create
11+
// the local age identity that SOPS_AGE_KEY_FILE points at, with no account and
12+
// fully offline. Pure-Go (filippo.io/age), so it works in a static binary.
13+
14+
// AgeKey is a generated age keypair: the secret identity (AGE-SECRET-KEY-…, kept
15+
// 0600) and its public recipient (age1…, shared / used as a SOPS recipient).
16+
type AgeKey struct {
17+
Identity string // AGE-SECRET-KEY-...
18+
Recipient string // age1...
19+
}
20+
21+
// GenerateAgeKey creates a fresh X25519 age identity.
22+
func GenerateAgeKey() (AgeKey, error) {
23+
id, err := age.GenerateX25519Identity()
24+
if err != nil {
25+
return AgeKey{}, fmt.Errorf("generate age identity: %w", err)
26+
}
27+
return AgeKey{Identity: id.String(), Recipient: id.Recipient().String()}, nil
28+
}
29+
30+
// AgeKeyFileContents renders the standard age key file body: a comment with the
31+
// public recipient (so the file is self-describing) followed by the secret key,
32+
// matching what `age-keygen` writes.
33+
func (k AgeKey) AgeKeyFileContents() string {
34+
return fmt.Sprintf("# public key: %s\n%s\n", k.Recipient, k.Identity)
35+
}

internal/secrets/keygen_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package secrets
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"filippo.io/age"
8+
)
9+
10+
func TestGenerateAgeKey(t *testing.T) {
11+
k, err := GenerateAgeKey()
12+
if err != nil {
13+
t.Fatal(err)
14+
}
15+
if !strings.HasPrefix(k.Identity, "AGE-SECRET-KEY-1") {
16+
t.Errorf("identity = %q, want an AGE-SECRET-KEY-1… secret", k.Identity)
17+
}
18+
if !strings.HasPrefix(k.Recipient, "age1") {
19+
t.Errorf("recipient = %q, want an age1… public key", k.Recipient)
20+
}
21+
// The generated identity must round-trip through age's parser and yield the
22+
// same recipient.
23+
id, err := age.ParseX25519Identity(k.Identity)
24+
if err != nil {
25+
t.Fatalf("generated identity does not parse: %v", err)
26+
}
27+
if id.Recipient().String() != k.Recipient {
28+
t.Errorf("recipient mismatch: parsed %q vs reported %q", id.Recipient(), k.Recipient)
29+
}
30+
// Two calls produce distinct keys.
31+
k2, _ := GenerateAgeKey()
32+
if k2.Identity == k.Identity {
33+
t.Error("two GenerateAgeKey calls produced the same identity")
34+
}
35+
}
36+
37+
func TestAgeKeyFileContents(t *testing.T) {
38+
k := AgeKey{Identity: "AGE-SECRET-KEY-1XXXX", Recipient: "age1yyyy"}
39+
body := k.AgeKeyFileContents()
40+
if !strings.Contains(body, "# public key: age1yyyy") || !strings.Contains(body, "AGE-SECRET-KEY-1XXXX") {
41+
t.Errorf("key file body = %q", body)
42+
}
43+
if !strings.HasSuffix(body, "\n") {
44+
t.Error("key file should end with a newline")
45+
}
46+
}

0 commit comments

Comments
 (0)