aboutsummaryrefslogtreecommitdiff
path: root/cmd/plutia
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-26 19:54:46 -0800
committerFuwn <[email protected]>2026-02-26 19:54:46 -0800
commit3b05299b6e0badb0dbda62d2470a6b78d8e67ff4 (patch)
tree877d1ae17271ecc1be885742c9a049abb069c55d /cmd/plutia
parentchore: update docker and compose development setup (diff)
downloadplutia-test-3b05299b6e0badb0dbda62d2470a6b78d8e67ff4.tar.xz
plutia-test-3b05299b6e0badb0dbda62d2470a6b78d8e67ff4.zip
feat: add keygen command and inject docker build metadata
Diffstat (limited to 'cmd/plutia')
-rw-r--r--cmd/plutia/keygen.go124
-rw-r--r--cmd/plutia/keygen_test.go83
-rw-r--r--cmd/plutia/main.go5
3 files changed, 212 insertions, 0 deletions
diff --git a/cmd/plutia/keygen.go b/cmd/plutia/keygen.go
new file mode 100644
index 0000000..f2f5820
--- /dev/null
+++ b/cmd/plutia/keygen.go
@@ -0,0 +1,124 @@
+package main
+
+import (
+ "crypto/ed25519"
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/hex"
+ "flag"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+func runKeygen(args []string) error {
+ fs := flag.NewFlagSet("keygen", flag.ExitOnError)
+ out := fs.String("out", "", "output private key path")
+ force := fs.Bool("force", false, "overwrite output file if it exists")
+
+ if err := fs.Parse(args); err != nil {
+ return err
+ }
+
+ if strings.TrimSpace(*out) == "" {
+ return fmt.Errorf("--out is required")
+ }
+
+ fingerprint, err := writeMirrorPrivateKey(*out, *force, rand.Reader)
+ if err != nil {
+ return err
+ }
+
+ fmt.Printf("generated key fingerprint=%s path=%s\n", fingerprint, *out)
+
+ return nil
+}
+
+func writeMirrorPrivateKey(out string, force bool, random io.Reader) (string, error) {
+ seed := make([]byte, ed25519.SeedSize)
+
+ if _, err := io.ReadFull(random, seed); err != nil {
+ return "", fmt.Errorf("generate seed: %w", err)
+ }
+
+ fingerprint := keyFingerprint(ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey))
+
+ if err := writeAtomicFile(out, append([]byte(hex.EncodeToString(seed)), '\n'), force, 0o600); err != nil {
+ return "", err
+ }
+
+ return fingerprint, nil
+}
+
+func writeAtomicFile(path string, data []byte, force bool, mode os.FileMode) error {
+ if path == "" {
+ return fmt.Errorf("output path is required")
+ }
+
+ if _, err := os.Stat(path); err == nil && !force {
+ return fmt.Errorf("output path already exists: %s (use --force to overwrite)", path)
+ } else if err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("stat output path: %w", err)
+ }
+
+ dir := filepath.Dir(path)
+
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return fmt.Errorf("mkdir output dir: %w", err)
+ }
+
+ tmp, err := os.CreateTemp(dir, ".plutia-keygen-*")
+ if err != nil {
+ return fmt.Errorf("create temp key file: %w", err)
+ }
+
+ tmpPath := tmp.Name()
+ closed := false
+ defer func() {
+ if !closed {
+ _ = tmp.Close()
+ }
+ _ = os.Remove(tmpPath)
+ }()
+
+ if err := tmp.Chmod(mode); err != nil {
+ return fmt.Errorf("chmod temp key file: %w", err)
+ }
+
+ if _, err := tmp.Write(data); err != nil {
+ return fmt.Errorf("write temp key file: %w", err)
+ }
+
+ if err := tmp.Sync(); err != nil {
+ return fmt.Errorf("sync temp key file: %w", err)
+ }
+
+ if err := tmp.Close(); err != nil {
+ return fmt.Errorf("close temp key file: %w", err)
+ }
+ closed = true
+
+ if force {
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
+ return fmt.Errorf("remove existing output file: %w", err)
+ }
+ }
+
+ if err := os.Rename(tmpPath, path); err != nil {
+ return fmt.Errorf("rename temp key file: %w", err)
+ }
+
+ if err := os.Chmod(path, mode); err != nil {
+ return fmt.Errorf("chmod output key file: %w", err)
+ }
+
+ return nil
+}
+
+func keyFingerprint(pub ed25519.PublicKey) string {
+ sum := sha256.Sum256(pub)
+
+ return "ed25519:" + hex.EncodeToString(sum[:8])
+}
diff --git a/cmd/plutia/keygen_test.go b/cmd/plutia/keygen_test.go
new file mode 100644
index 0000000..533eec3
--- /dev/null
+++ b/cmd/plutia/keygen_test.go
@@ -0,0 +1,83 @@
+package main
+
+import (
+ "bytes"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestWriteMirrorPrivateKey(t *testing.T) {
+ t.Parallel()
+
+ seed := bytes.Repeat([]byte{0x42}, ed25519.SeedSize)
+ out := filepath.Join(t.TempDir(), "mirror.key")
+
+ fingerprint, err := writeMirrorPrivateKey(out, false, bytes.NewReader(seed))
+ if err != nil {
+ t.Fatalf("write key: %v", err)
+ }
+
+ b, err := os.ReadFile(out)
+ if err != nil {
+ t.Fatalf("read key file: %v", err)
+ }
+
+ expectedFile := hex.EncodeToString(seed) + "\n"
+ if string(b) != expectedFile {
+ t.Fatalf("key file content mismatch: got %q want %q", string(b), expectedFile)
+ }
+
+ pub := ed25519.NewKeyFromSeed(seed).Public().(ed25519.PublicKey)
+ sum := sha256.Sum256(pub)
+ expectedFP := "ed25519:" + hex.EncodeToString(sum[:8])
+ if fingerprint != expectedFP {
+ t.Fatalf("fingerprint mismatch: got %s want %s", fingerprint, expectedFP)
+ }
+}
+
+func TestWriteMirrorPrivateKey_NoForceRefusesOverwrite(t *testing.T) {
+ t.Parallel()
+
+ out := filepath.Join(t.TempDir(), "mirror.key")
+ if err := os.WriteFile(out, []byte("existing\n"), 0o600); err != nil {
+ t.Fatalf("seed existing file: %v", err)
+ }
+
+ _, err := writeMirrorPrivateKey(out, false, bytes.NewReader(bytes.Repeat([]byte{0x01}, ed25519.SeedSize)))
+ if err == nil {
+ t.Fatalf("expected overwrite refusal error")
+ }
+
+ if !strings.Contains(err.Error(), "already exists") {
+ t.Fatalf("unexpected error: %v", err)
+ }
+}
+
+func TestWriteMirrorPrivateKey_ForceOverwrites(t *testing.T) {
+ t.Parallel()
+
+ out := filepath.Join(t.TempDir(), "mirror.key")
+ if err := os.WriteFile(out, []byte("existing\n"), 0o600); err != nil {
+ t.Fatalf("seed existing file: %v", err)
+ }
+
+ seed := bytes.Repeat([]byte{0x03}, ed25519.SeedSize)
+ if _, err := writeMirrorPrivateKey(out, true, bytes.NewReader(seed)); err != nil {
+ t.Fatalf("force overwrite key: %v", err)
+ }
+
+ b, err := os.ReadFile(out)
+ if err != nil {
+ t.Fatalf("read overwritten file: %v", err)
+ }
+
+ expectedFile := hex.EncodeToString(seed) + "\n"
+ if string(b) != expectedFile {
+ t.Fatalf("overwritten content mismatch: got %q want %q", string(b), expectedFile)
+ }
+}
diff --git a/cmd/plutia/main.go b/cmd/plutia/main.go
index 1c533af..2a507b5 100644
--- a/cmd/plutia/main.go
+++ b/cmd/plutia/main.go
@@ -72,6 +72,10 @@ func main() {
if err := runCompare(os.Args[2:]); err != nil {
log.Fatal(err)
}
+ case "keygen":
+ if err := runKeygen(os.Args[2:]); err != nil {
+ log.Fatal(err)
+ }
case "version":
if err := runVersion(); err != nil {
log.Fatal(err)
@@ -532,6 +536,7 @@ Commands:
plutia snapshot --config=config.default.yaml
plutia bench --config=config.default.yaml [--max-ops=200000] [--interval=10s]
plutia compare --config=config.default.yaml --remote=https://mirror.example.com
+ plutia keygen --out=./mirror.key [--force]
plutia version
`)
}