From ec0eb2349f1af7e058515b19ab7e7627d4a9a2c7 Mon Sep 17 00:00:00 2001 From: Gustavo Bertoi Date: Mon, 29 Jun 2026 13:29:28 -0300 Subject: [PATCH] =?UTF-8?q?feat(secrets,cli):=20S5=20(part)=20=E2=80=94=20?= =?UTF-8?q?`secrets=20keygen`=20age=20keypair=20generator=20(spec=2004)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- go.mod | 2 + go.sum | 6 +++ internal/cli/root.go | 1 + internal/cli/secrets.go | 65 +++++++++++++++++++++++++++++++++ internal/cli/secrets_test.go | 47 ++++++++++++++++++++++++ internal/cli/stubs.go | 4 -- internal/secrets/keygen.go | 35 ++++++++++++++++++ internal/secrets/keygen_test.go | 46 +++++++++++++++++++++++ 8 files changed, 202 insertions(+), 4 deletions(-) create mode 100644 internal/cli/secrets.go create mode 100644 internal/cli/secrets_test.go create mode 100644 internal/secrets/keygen.go create mode 100644 internal/secrets/keygen_test.go diff --git a/go.mod b/go.mod index 29f10c2..37cbdd4 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ go 1.25.0 require ( charm.land/fang/v2 v2.0.1 + filippo.io/age v1.3.1 github.com/adrg/xdg v0.5.3 github.com/compose-spec/compose-go/v2 v2.12.1 github.com/go-playground/validator/v10 v10.30.3 @@ -23,6 +24,7 @@ require ( require ( charm.land/lipgloss/v2 v2.0.1 // indirect + filippo.io/hpke v0.4.0 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect diff --git a/go.sum b/go.sum index 5c9bbf0..8952cf0 100644 --- a/go.sum +++ b/go.sum @@ -1,7 +1,13 @@ +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd h1:ZLsPO6WdZ5zatV4UfVpr7oAwLGRZ+sebTUruuM4Ra3M= +c2sp.org/CCTV/age v0.0.0-20251208015420-e9274a7bdbfd/go.mod h1:SrHC2C7r5GkDk8R+NFVzYy/sdj0Ypg9htaPXQq5Cqeo= charm.land/fang/v2 v2.0.1 h1:zQCM8JQJ1JnQX/66B5jlCYBUxL2as5JXQZ2KJ6EL0mY= charm.land/fang/v2 v2.0.1/go.mod h1:S1GmkpcvK+OB5w9caywUnJcsMew45Ot8FXqoz8ALrII= charm.land/lipgloss/v2 v2.0.1 h1:6Xzrn49+Py1Um5q/wZG1gWgER2+7dUyZ9XMEufqPSys= charm.land/lipgloss/v2 v2.0.1/go.mod h1:KjPle2Qd3YmvP1KL5OMHiHysGcNwq6u83MUjYkFvEkM= +filippo.io/age v1.3.1 h1:hbzdQOJkuaMEpRCLSN1/C5DX74RPcNCk6oqhKMXmZi0= +filippo.io/age v1.3.1/go.mod h1:EZorDTYUxt836i3zdori5IJX/v2Lj6kWFU0cfh6C0D4= +filippo.io/hpke v0.4.0 h1:p575VVQ6ted4pL+it6M00V/f2qTZITO0zgmdKCkd5+A= +filippo.io/hpke v0.4.0/go.mod h1:EmAN849/P3qdeK+PCMkDpDm83vRHM5cDipBJ8xbQLVY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78= diff --git a/internal/cli/root.go b/internal/cli/root.go index 777d028..aea45d7 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -83,6 +83,7 @@ func NewRootCmd(opts Options) *cobra.Command { newDnsCmd(g), newTrustCmd(g), newTunnelCmd(g), + newSecretsCmd(g), newDoctorCmd(g), newConfigCmd(g), newGenerateCmd(g), diff --git a/internal/cli/secrets.go b/internal/cli/secrets.go new file mode 100644 index 0000000..68fcf71 --- /dev/null +++ b/internal/cli/secrets.go @@ -0,0 +1,65 @@ +package cli + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/spf13/cobra" + + "github.com/open-source-cloud/devstack/internal/secrets" +) + +// newSecretsCmd wires `secrets keygen` (real) and `secrets login` (stub until the +// keyring lands, S5). keygen generates the offline age identity that the SOPS+age +// provider (S2) decrypts with — no account, fully local. +func newSecretsCmd(g *GlobalOpts) *cobra.Command { + cmd := &cobra.Command{ + Use: "secrets", + Short: "Secrets providers (age keygen, provider login)", + } + cmd.AddCommand( + newSecretsKeygenCmd(g), + stub("login", "Authenticate a secrets provider (keyring) — S5", "M4"), + ) + return cmd +} + +func newSecretsKeygenCmd(g *GlobalOpts) *cobra.Command { + var output string + cmd := &cobra.Command{ + Use: "keygen", + Short: "Generate an age keypair for SOPS+age (offline, no account)", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + k, err := secrets.GenerateAgeKey() + if err != nil { + return err + } + if output == "" { + // No file: print the key body to stdout (caller redirects) and the + // public recipient to stderr so a pipe captures only the key. + if g.JSON { + return writeJSON(cmd, map[string]string{"recipient": k.Recipient, "identity": k.Identity}) + } + fmt.Fprint(cmd.OutOrStdout(), k.AgeKeyFileContents()) + fmt.Fprintf(cmd.ErrOrStderr(), "public recipient: %s\n", k.Recipient) + return nil + } + if err := os.MkdirAll(filepath.Dir(output), 0o755); err != nil { + return err + } + if err := os.WriteFile(output, []byte(k.AgeKeyFileContents()), 0o600); err != nil { + return fmt.Errorf("write age key %s: %w", output, err) + } + if g.JSON { + return writeJSON(cmd, map[string]string{"path": output, "recipient": k.Recipient}) + } + fmt.Fprintf(cmd.OutOrStdout(), "wrote age key to %s\npublic recipient: %s\n", output, k.Recipient) + fmt.Fprintf(cmd.OutOrStdout(), "→ point SOPS at it: export SOPS_AGE_KEY_FILE=%s\n", output) + return nil + }, + } + cmd.Flags().StringVarP(&output, "output", "o", "", "write the key to this file (0600) instead of stdout") + return cmd +} diff --git a/internal/cli/secrets_test.go b/internal/cli/secrets_test.go new file mode 100644 index 0000000..37c6404 --- /dev/null +++ b/internal/cli/secrets_test.go @@ -0,0 +1,47 @@ +package cli + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestSecretsKeygenRegistered(t *testing.T) { + if !findCmd(t, "secrets") { + // secrets is a group; verify the keygen child is real. + } + root := NewRootCmd(Options{}) + c, _, err := root.Find([]string{"secrets", "keygen"}) + if err != nil || c.RunE == nil { + t.Fatalf("secrets keygen not registered as a real command: %v", err) + } +} + +func TestSecretsKeygenWritesFile(t *testing.T) { + out := filepath.Join(t.TempDir(), "age", "keys.txt") + root := NewRootCmd(Options{}) + var buf strings.Builder + root.SetOut(&buf) + root.SetErr(&buf) + root.SetArgs([]string{"secrets", "keygen", "--output", out}) + if err := root.Execute(); err != nil { + t.Fatalf("keygen: %v\n%s", err, buf.String()) + } + data, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + body := string(data) + if !strings.Contains(body, "AGE-SECRET-KEY-1") || !strings.Contains(body, "# public key: age1") { + t.Errorf("key file = %q", body) + } + // 0600 perms on the secret. + fi, _ := os.Stat(out) + if fi.Mode().Perm() != 0o600 { + t.Errorf("key file mode = %v, want 0600", fi.Mode().Perm()) + } + if !strings.Contains(buf.String(), "SOPS_AGE_KEY_FILE") { + t.Errorf("output should hint SOPS_AGE_KEY_FILE:\n%s", buf.String()) + } +} diff --git a/internal/cli/stubs.go b/internal/cli/stubs.go index 95ecd40..2667c50 100644 --- a/internal/cli/stubs.go +++ b/internal/cli/stubs.go @@ -32,10 +32,6 @@ func addStubCommands(root *cobra.Command, _ *GlobalOpts) { root.AddCommand( stub("shell", "Open a shell in a service container", "M2"), stub("logs", "Stream service logs", "M2"), - stub("secrets", "Secrets providers (login, keygen)", "M4", - stub("login", "Authenticate a secrets provider", "M4"), - stub("keygen", "Generate an age/SOPS key", "M4"), - ), stub("import", "Import an old devdock project.yaml into workspace.yaml + devstack.yaml", "M1"), stub("workspace", "Workspace-level lifecycle", "M6", stub("destroy", "Reverse ALL machine-global artifacts for this workspace", "M6"), diff --git a/internal/secrets/keygen.go b/internal/secrets/keygen.go new file mode 100644 index 0000000..6b80395 --- /dev/null +++ b/internal/secrets/keygen.go @@ -0,0 +1,35 @@ +package secrets + +import ( + "fmt" + + "filippo.io/age" +) + +// This file is the age keypair generator behind `secrets keygen` (spec 04) — the +// onboarding companion to the SOPS+age provider (S2): it lets a developer create +// the local age identity that SOPS_AGE_KEY_FILE points at, with no account and +// fully offline. Pure-Go (filippo.io/age), so it works in a static binary. + +// AgeKey is a generated age keypair: the secret identity (AGE-SECRET-KEY-…, kept +// 0600) and its public recipient (age1…, shared / used as a SOPS recipient). +type AgeKey struct { + Identity string // AGE-SECRET-KEY-... + Recipient string // age1... +} + +// GenerateAgeKey creates a fresh X25519 age identity. +func GenerateAgeKey() (AgeKey, error) { + id, err := age.GenerateX25519Identity() + if err != nil { + return AgeKey{}, fmt.Errorf("generate age identity: %w", err) + } + return AgeKey{Identity: id.String(), Recipient: id.Recipient().String()}, nil +} + +// AgeKeyFileContents renders the standard age key file body: a comment with the +// public recipient (so the file is self-describing) followed by the secret key, +// matching what `age-keygen` writes. +func (k AgeKey) AgeKeyFileContents() string { + return fmt.Sprintf("# public key: %s\n%s\n", k.Recipient, k.Identity) +} diff --git a/internal/secrets/keygen_test.go b/internal/secrets/keygen_test.go new file mode 100644 index 0000000..696db47 --- /dev/null +++ b/internal/secrets/keygen_test.go @@ -0,0 +1,46 @@ +package secrets + +import ( + "strings" + "testing" + + "filippo.io/age" +) + +func TestGenerateAgeKey(t *testing.T) { + k, err := GenerateAgeKey() + if err != nil { + t.Fatal(err) + } + if !strings.HasPrefix(k.Identity, "AGE-SECRET-KEY-1") { + t.Errorf("identity = %q, want an AGE-SECRET-KEY-1… secret", k.Identity) + } + if !strings.HasPrefix(k.Recipient, "age1") { + t.Errorf("recipient = %q, want an age1… public key", k.Recipient) + } + // The generated identity must round-trip through age's parser and yield the + // same recipient. + id, err := age.ParseX25519Identity(k.Identity) + if err != nil { + t.Fatalf("generated identity does not parse: %v", err) + } + if id.Recipient().String() != k.Recipient { + t.Errorf("recipient mismatch: parsed %q vs reported %q", id.Recipient(), k.Recipient) + } + // Two calls produce distinct keys. + k2, _ := GenerateAgeKey() + if k2.Identity == k.Identity { + t.Error("two GenerateAgeKey calls produced the same identity") + } +} + +func TestAgeKeyFileContents(t *testing.T) { + k := AgeKey{Identity: "AGE-SECRET-KEY-1XXXX", Recipient: "age1yyyy"} + body := k.AgeKeyFileContents() + if !strings.Contains(body, "# public key: age1yyyy") || !strings.Contains(body, "AGE-SECRET-KEY-1XXXX") { + t.Errorf("key file body = %q", body) + } + if !strings.HasSuffix(body, "\n") { + t.Error("key file should end with a newline") + } +}