Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
6 changes: 6 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
Expand Down
1 change: 1 addition & 0 deletions internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ func NewRootCmd(opts Options) *cobra.Command {
newDnsCmd(g),
newTrustCmd(g),
newTunnelCmd(g),
newSecretsCmd(g),
newDoctorCmd(g),
newConfigCmd(g),
newGenerateCmd(g),
Expand Down
65 changes: 65 additions & 0 deletions internal/cli/secrets.go
Original file line number Diff line number Diff line change
@@ -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
}
47 changes: 47 additions & 0 deletions internal/cli/secrets_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
4 changes: 0 additions & 4 deletions internal/cli/stubs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
35 changes: 35 additions & 0 deletions internal/secrets/keygen.go
Original file line number Diff line number Diff line change
@@ -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)
}
46 changes: 46 additions & 0 deletions internal/secrets/keygen_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading