From e73809cf8d9049e8ab01ee15ba586753b3b84e71 Mon Sep 17 00:00:00 2001 From: Virgil Date: Thu, 2 Apr 2026 14:24:55 +0000 Subject: [PATCH] feat(cmd/scm): add manifest sign and verify commands Co-Authored-By: Virgil --- cmd/scm/cmd_scm.go | 4 + cmd/scm/cmd_sign_verify.go | 137 ++++++++++++++++++++++++++++++++ cmd/scm/cmd_sign_verify_test.go | 99 +++++++++++++++++++++++ 3 files changed, 240 insertions(+) create mode 100644 cmd/scm/cmd_sign_verify.go create mode 100644 cmd/scm/cmd_sign_verify_test.go diff --git a/cmd/scm/cmd_scm.go b/cmd/scm/cmd_scm.go index 611ddc1..d6b00fd 100644 --- a/cmd/scm/cmd_scm.go +++ b/cmd/scm/cmd_scm.go @@ -7,6 +7,8 @@ // - compile: Compile .core/manifest.yaml into core.json // - index: Build marketplace index from repository directories // - export: Export a compiled manifest as JSON to stdout +// - sign: Sign .core/manifest.yaml with an ed25519 private key +// - verify: Verify a manifest signature with an ed25519 public key package scm import ( @@ -39,4 +41,6 @@ func AddScmCommands(root *cli.Command) { addCompileCommand(scmCmd) addIndexCommand(scmCmd) addExportCommand(scmCmd) + addSignCommand(scmCmd) + addVerifyCommand(scmCmd) } diff --git a/cmd/scm/cmd_sign_verify.go b/cmd/scm/cmd_sign_verify.go new file mode 100644 index 0000000..97cdf5f --- /dev/null +++ b/cmd/scm/cmd_sign_verify.go @@ -0,0 +1,137 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package scm + +import ( + "crypto/ed25519" + "encoding/hex" + + "dappco.re/go/core/io" + "dappco.re/go/core/scm/manifest" + "forge.lthn.ai/core/cli/pkg/cli" +) + +func addSignCommand(parent *cli.Command) { + var ( + dir string + signKey string + ) + + cmd := &cli.Command{ + Use: "sign", + Short: "Sign manifest.yaml with a private key", + Long: "Read .core/manifest.yaml, attach an ed25519 signature, and write the signed manifest back to disk.", + RunE: func(cmd *cli.Command, args []string) error { + return runSign(dir, signKey) + }, + } + + cmd.Flags().StringVarP(&dir, "dir", "d", ".", "Project root directory") + cmd.Flags().StringVar(&signKey, "sign-key", "", "Hex-encoded ed25519 private key") + + parent.AddCommand(cmd) +} + +func runSign(dir, signKeyHex string) error { + if signKeyHex == "" { + return cli.Err("sign key is required") + } + + medium, err := io.NewSandboxed(dir) + if err != nil { + return cli.WrapVerb(err, "open", dir) + } + + m, err := manifest.Load(medium, ".") + if err != nil { + return cli.WrapVerb(err, "load", "manifest") + } + + keyBytes, err := hex.DecodeString(signKeyHex) + if err != nil { + return cli.WrapVerb(err, "decode", "sign key") + } + if len(keyBytes) != ed25519.PrivateKeySize { + return cli.Err("sign key must be %d bytes when decoded", ed25519.PrivateKeySize) + } + + if err := manifest.Sign(m, ed25519.PrivateKey(keyBytes)); err != nil { + return err + } + + data, err := manifest.MarshalYAML(m) + if err != nil { + return cli.WrapVerb(err, "marshal", "manifest") + } + + if err := medium.Write(".core/manifest.yaml", string(data)); err != nil { + return cli.WrapVerb(err, "write", ".core/manifest.yaml") + } + + cli.Blank() + cli.Print(" %s %s\n", successStyle.Render("signed"), valueStyle.Render(m.Code)) + cli.Print(" %s %s\n", dimStyle.Render("output:"), valueStyle.Render(".core/manifest.yaml")) + cli.Blank() + + return nil +} + +func addVerifyCommand(parent *cli.Command) { + var ( + dir string + publicKey string + ) + + cmd := &cli.Command{ + Use: "verify", + Short: "Verify manifest signature with a public key", + Long: "Read .core/manifest.yaml and verify its ed25519 signature against a public key.", + RunE: func(cmd *cli.Command, args []string) error { + return runVerify(dir, publicKey) + }, + } + + cmd.Flags().StringVarP(&dir, "dir", "d", ".", "Project root directory") + cmd.Flags().StringVar(&publicKey, "public-key", "", "Hex-encoded ed25519 public key") + + parent.AddCommand(cmd) +} + +func runVerify(dir, publicKeyHex string) error { + if publicKeyHex == "" { + return cli.Err("public key is required") + } + + medium, err := io.NewSandboxed(dir) + if err != nil { + return cli.WrapVerb(err, "open", dir) + } + + m, err := manifest.Load(medium, ".") + if err != nil { + return cli.WrapVerb(err, "load", "manifest") + } + + keyBytes, err := hex.DecodeString(publicKeyHex) + if err != nil { + return cli.WrapVerb(err, "decode", "public key") + } + if len(keyBytes) != ed25519.PublicKeySize { + return cli.Err("public key must be %d bytes when decoded", ed25519.PublicKeySize) + } + + valid, err := manifest.Verify(m, ed25519.PublicKey(keyBytes)) + if err != nil { + return cli.WrapVerb(err, "verify", "manifest") + } + if !valid { + return cli.Err("signature verification failed for %s", m.Code) + } + + cli.Blank() + cli.Success("Signature verified") + cli.Print(" %s %s\n", dimStyle.Render("code:"), valueStyle.Render(m.Code)) + cli.Blank() + + return nil +} diff --git a/cmd/scm/cmd_sign_verify_test.go b/cmd/scm/cmd_sign_verify_test.go new file mode 100644 index 0000000..e0414a3 --- /dev/null +++ b/cmd/scm/cmd_sign_verify_test.go @@ -0,0 +1,99 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package scm + +import ( + "crypto/ed25519" + filepath "dappco.re/go/core/scm/internal/ax/filepathx" + os "dappco.re/go/core/scm/internal/ax/osx" + "encoding/hex" + "testing" + + "dappco.re/go/core/io" + "dappco.re/go/core/scm/manifest" + "forge.lthn.ai/core/cli/pkg/cli" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunSign_Good_WritesSignedManifest_Good(t *testing.T) { + dir := t.TempDir() + coreDir := filepath.Join(dir, ".core") + require.NoError(t, os.MkdirAll(coreDir, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(coreDir, "manifest.yaml"), []byte(` +code: signed-cli +name: Signed CLI +version: 1.0.0 +`), 0644)) + + pub, priv, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + + err = runSign(dir, hex.EncodeToString(priv)) + require.NoError(t, err) + + raw, err := io.Local.Read(filepath.Join(dir, ".core", "manifest.yaml")) + require.NoError(t, err) + + m, err := manifest.Parse([]byte(raw)) + require.NoError(t, err) + assert.Equal(t, "signed-cli", m.Code) + assert.NotEmpty(t, m.Sign) + + valid, err := manifest.Verify(m, pub) + require.NoError(t, err) + assert.True(t, valid) +} + +func TestRunVerify_Good_ValidSignature_Good(t *testing.T) { + dir := t.TempDir() + coreDir := filepath.Join(dir, ".core") + require.NoError(t, os.MkdirAll(coreDir, 0755)) + + pub, priv, err := ed25519.GenerateKey(nil) + require.NoError(t, err) + + m := &manifest.Manifest{ + Code: "verified-cli", + Name: "Verified CLI", + Version: "1.0.0", + } + require.NoError(t, manifest.Sign(m, priv)) + + data, err := manifest.MarshalYAML(m) + require.NoError(t, err) + require.NoError(t, os.WriteFile(filepath.Join(coreDir, "manifest.yaml"), data, 0644)) + + err = runVerify(dir, hex.EncodeToString(pub)) + require.NoError(t, err) +} + +func TestAddScmCommands_Good_SignAndVerifyRegistered_Good(t *testing.T) { + root := &cli.Command{Use: "root"} + + AddScmCommands(root) + + var scmCmd *cli.Command + for _, cmd := range root.Commands() { + if cmd.Name() == "scm" { + scmCmd = cmd + break + } + } + require.NotNil(t, scmCmd) + + var signCmd *cli.Command + var verifyCmd *cli.Command + for _, cmd := range scmCmd.Commands() { + switch cmd.Name() { + case "sign": + signCmd = cmd + case "verify": + verifyCmd = cmd + } + } + require.NotNil(t, signCmd) + require.NotNil(t, verifyCmd) + assert.NotNil(t, signCmd.Flags().Lookup("sign-key")) + assert.NotNil(t, verifyCmd.Flags().Lookup("public-key")) +}