diff options
| author | Fuwn <[email protected]> | 2026-02-26 19:54:46 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-02-26 19:54:46 -0800 |
| commit | 3b05299b6e0badb0dbda62d2470a6b78d8e67ff4 (patch) | |
| tree | 877d1ae17271ecc1be885742c9a049abb069c55d /cmd/plutia | |
| parent | chore: update docker and compose development setup (diff) | |
| download | plutia-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.go | 124 | ||||
| -rw-r--r-- | cmd/plutia/keygen_test.go | 83 | ||||
| -rw-r--r-- | cmd/plutia/main.go | 5 |
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 `) } |