diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..99ba961 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.vscode +*.exe +cover.out +coverage.html \ No newline at end of file diff --git a/README.md b/README.md index 8973b18..d95a05b 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,74 @@ -# git-ssh-sign -Git SSH sign feature for Keeper Secrets Manager +# Sign Git commits with SSH Keys + +Signing your git commits is important. It verifies authorship, ensures the integrity of committed content, prevents identity spoofing, and establishes non-repudiation. Using a cryptographic signature with your private key demonstrates a commitment to the authenticity and security of your contributions, building trust among collaborators and protecting the repository from potential tampering and malicious code. + +This integration will let you sign git commits with an SSH key in your Keeper Vault (via Keeper Secrets Manager) rather than using a key stored on disk. + +## Requirements + +Development requires: + +- Git > 2.34.0 +- Go > 1.20 +- [Keeper Secrets Manager Enabled](https://docs.keeper.io/secrets-manager/secrets-manager/quick-start-guide) + +## Set up + +### Secrets Manager Configuration + +This integration uses the zero-knowledge Secrets Manager to fetch the SSH key from your vault. It expects to find the Secrets Manager configuration file at `.keeper/ssh/config.json` in the user's home directory for Windows and UNIX systems. If this configuration is not found, it will also check `.keeper/config.json` for an existing configuration from another integration. **The Secrets Manager application must have access to the shared folder in which your SSH key is stored**. + +For help in setting up your application and obtaining your configuration file, you can find [detailed instructions here](https://docs.keeper.io/secrets-manager/secrets-manager/about/secrets-manager-configuration#creating-a-secrets-manager-configuration) + +### Git Config + +After successfully configuring Secrets Manager, you can now configure Git to sign your commits automatically. This can be done locally or globally, depending on your needs. + +Four pieces of information are necessary for your config: + +1. Tell git you want to sign all commits. +2. Tell git you want to use SSH signing over the default GPG signing. +3. Tell git the location of this integrations binary. +4. Tell git the UID of the SSH key to be used to sign. + +We can do this locally with the following commands (add the `--global` flag to set these globally): + +```shell +git config commit.gpgsign true +git config gpg.format ssh +git config gpg.ssh.program +git config user.signingkey +``` + +Your git config will now include these attributes: + +```ini +[commit] + gpgsign = true +[gpg] + format = ssh +[user] + signingKey = /tmp/.git_signing_buffer_file + + The is the user.signingkey value from the git config. This will + be the UID of the record in the Vault. + The /tmp/.git_signing_buffer_file is the file that contains the commit + data that is to be signed. + + We need to: + 1. Fetch the private key from the Vault based on the UID. + 2. Sign the commit. + 3. Write the signature to a file. The file name should be the same as + the commit file but with a .sig extension. + + As long as the program returns a 0 exit code, git will continue with + the commit, even if incorrectly signed. git wil not verify the + signature at the time of commiting. If the exit code is non-zero, + git will abort the commit. + */ + + args := os.Args + commitToSign := args[len(args)-1] + sshUID := args[len(args)-2] + + privateKey, err := vault.FetchPrivateKey(sshUID) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + file, err := os.Open(commitToSign) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + // To ensure that git can read the final signature file, we capture the + // file permissions of the commit file to ensure the signature file has the + // same permissions. + fileinfo, err := os.Stat(commitToSign) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + fileMode := fileinfo.Mode() + + sig, err := sign.SignCommit(privateKey, file) + if err != nil { + fmt.Println(err) + os.Exit(1) + } + + os.WriteFile(fmt.Sprintf("%s.sig", commitToSign), sig, fileMode) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..d070059 --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/Keeper-Security/git-ssh-sign + +go 1.20 + +require ( + github.com/keeper-security/secrets-manager-go/core v1.5.2 + golang.org/x/crypto v0.10.0 +) + +require golang.org/x/sys v0.9.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5895535 --- /dev/null +++ b/go.sum @@ -0,0 +1,7 @@ +github.com/keeper-security/secrets-manager-go/core v1.5.2 h1:Uo5JU3OMK2NJ+mmoPogZ7kNS0xpXXskUv0RDo0mtygs= +github.com/keeper-security/secrets-manager-go/core v1.5.2/go.mod h1:dtlaeeds9+SZsbDAZnQRsDSqEAK9a62SYtqhNql+VgQ= +golang.org/x/crypto v0.10.0 h1:LKqV2xt9+kDzSTfOhx4FrkEBcMrAgHSYgzywV9zcGmM= +golang.org/x/crypto v0.10.0/go.mod h1:o4eNf7Ede1fv+hwOwZsTHl9EsPFO6q6ZvYR8vYfY45I= +golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= +golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28= diff --git a/internal/sign/sign.go b/internal/sign/sign.go new file mode 100644 index 0000000..1a45f59 --- /dev/null +++ b/internal/sign/sign.go @@ -0,0 +1,110 @@ +package sign + +import ( + "crypto/rand" + "crypto/sha512" + "encoding/pem" + "golang.org/x/crypto/ssh" + "io" +) + +/* + The code in this module is heavily based on the great work done by the + sigstore/rekor project: https://github.com/sigstore/rekor/ +*/ + +// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig#L81 +type MessageWrapper struct { + Namespace string + Reserved string + HashAlgorithm string + Hash string +} + +// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig#L34 +type WrappedSig struct { + MagicHeader [6]byte + Version uint32 + PublicKey string + Namespace string + Reserved string + HashAlgorithm string + Signature string +} + +const ( + magicHeader = "SSHSIG" + defaultHashAlgorithm = "sha512" + namespace = "git" +) + +// Create an Armored (PEM) Signature +func armor(sshSig *ssh.Signature, pubKey ssh.PublicKey) []byte { + sig := WrappedSig{ + Version: 1, + PublicKey: string(pubKey.Marshal()), + Namespace: namespace, + HashAlgorithm: defaultHashAlgorithm, + Signature: string(ssh.Marshal(sshSig)), + } + + copy(sig.MagicHeader[:], magicHeader) + + enc := pem.EncodeToMemory(&pem.Block{ + Type: "SSH SIGNATURE", + Bytes: ssh.Marshal(sig), + }) + return enc +} + +// Create a signature for the given data using the given signer. +func signature(signer ssh.AlgorithmSigner, data io.Reader) (*ssh.Signature, error) { + hf := sha512.New() + if _, err := io.Copy(hf, data); err != nil { + return nil, err + } + mh := hf.Sum(nil) + + sp := MessageWrapper{ + Namespace: namespace, + HashAlgorithm: defaultHashAlgorithm, + Hash: string(mh), + } + + dataMessageWrapper := ssh.Marshal(sp) + dataMessageWrapper = append([]byte(magicHeader), dataMessageWrapper...) + + // ssh-rsa is not supported for RSA keys: + // https://github.com/openssh/openssh-portable/blob/master/PROTOCOL.sshsig#L71 + // We can use the default value of "" for other key types though. + algo := "" + if signer.PublicKey().Type() == ssh.KeyAlgoRSA { + algo = ssh.KeyAlgoRSASHA512 + } + sig, err := signer.SignWithAlgorithm(rand.Reader, dataMessageWrapper, algo) + if err != nil { + return nil, err + } + return sig, nil +} + +// Sign a commit(data) using the given private key. +func SignCommit(sshPrivateKey string, data io.Reader) ([]byte, error) { + s, err := ssh.ParsePrivateKey([]byte(sshPrivateKey)) + if err != nil { + return nil, err + } + + as, ok := s.(ssh.AlgorithmSigner) + if !ok { + return nil, err + } + + sig, err := signature(as, data) + if err != nil { + return nil, err + } + + armored := armor(sig, s.PublicKey()) + return armored, nil +} diff --git a/internal/sign/sign_test.go b/internal/sign/sign_test.go new file mode 100644 index 0000000..63916b6 --- /dev/null +++ b/internal/sign/sign_test.go @@ -0,0 +1,97 @@ +package sign + +import ( + "strings" + "testing" +) + +var ( + // The following value was generated using the following command: + // ssh-keygen -C test@example.com -t ed25519 -f test_key + ed25519PrivateKey = `-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACCz+xopc6E4JvXVzyjCo+XGFuLePWU4641LMeAk1zkJ9AAAAJgzIeG6MyHh +ugAAAAtzc2gtZWQyNTUxOQAAACCz+xopc6E4JvXVzyjCo+XGFuLePWU4641LMeAk1zkJ9A +AAAEAF5dYQF/fBefn+Kn7M+1BjY6JZ/9TnOpeXQeMmNiv607P7GilzoTgm9dXPKMKj5cYW +4t49ZTjrjUsx4CTXOQn0AAAAEHRlc3RAZXhhbXBsZS5jb20BAgMEBQ== +-----END OPENSSH PRIVATE KEY----- +` + + // The following value was generated using the following command: + // ssh-keygen -C test@example -f test_key + rsaPrivateKey = `-----BEGIN OPENSSH PRIVATE KEY----- +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 +-----END OPENSSH PRIVATE KEY----- +` +) + +func TestSignCommit(t *testing.T) { + data := strings.NewReader("test data") + + tests := []struct { + name string + key string + wantErr bool + }{ + { + name: "ED25519 Key", + key: ed25519PrivateKey, + wantErr: false, + }, + { + name: "RSA Key", + key: rsaPrivateKey, + wantErr: false, + }, + { + name: "Invalid Key", + key: "invalid key", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := SignCommit(tt.key, data) + if (err != nil) != tt.wantErr { + t.Errorf("TestSignCommit expected: %v, got: %v", tt.wantErr, err) + return + } + }) + } +} diff --git a/internal/vault/vault.go b/internal/vault/vault.go new file mode 100644 index 0000000..48f0109 --- /dev/null +++ b/internal/vault/vault.go @@ -0,0 +1,71 @@ +package vault + +import ( + "fmt" + "os" + "path/filepath" + + ksm "github.com/keeper-security/secrets-manager-go/core" +) + +type ConfigOptions struct { + ConfigFile string + ConfigFileBackup string +} + +// Build the config options based on the given options. +func buildConfigOptions(h string) ConfigOptions { + return ConfigOptions{ + ConfigFile: filepath.Join(h, ".keeper", "ssh", "config.json"), + ConfigFileBackup: filepath.Join(h, ".keeper", "config.json"), + } +} + +// Find the config.json file +func getConfig(options ConfigOptions) (string, error) { + // If the ConfigFile exists, use it, else check ConfigFileBackup. If + // neither exist, returns an error. + if _, err := os.Stat(options.ConfigFile); err == nil { + return options.ConfigFile, nil + } else if _, err := os.Stat(options.ConfigFileBackup); err == nil { + return options.ConfigFileBackup, nil + } else { + return "", fmt.Errorf("config file not found") + } +} + +// Fetch a private key from the Vault via the Keeper Secrets Manager based on +// the UID in the git config. +func FetchPrivateKey(uid string) (string, error) { + + homeDir, err := os.UserHomeDir() + if err != nil { + return "", err + } + + // Get config file path to be used for the KSM + config, err := getConfig(buildConfigOptions(homeDir)) + if err != nil || config == "" { + fmt.Println(err) + os.Exit(1) + } + + sm := ksm.NewSecretsManager( + &ksm.ClientOptions{Config: ksm.NewFileKeyValueStorage(config)}) + + records, err := sm.GetSecrets([]string{uid}) + if err != nil { + return "", err + } + if len(records) == 0 { + return "", fmt.Errorf("no records found for UID: %s", uid) + } + + // GetFieldsByType returns an array of Field objects that match the + // specified type. In this case, we are filtering for fields of type + // "keyPair", as we only care about the private key. + keys := records[0].GetFieldsByType("keyPair")[0]["value"].([]interface{})[0] + privateKey := keys.(map[string]interface{})["privateKey"].(string) + + return privateKey, nil +} diff --git a/internal/vault/vault_test.go b/internal/vault/vault_test.go new file mode 100644 index 0000000..616c13a --- /dev/null +++ b/internal/vault/vault_test.go @@ -0,0 +1,117 @@ +package vault + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +var testHomeDir string + +// Create a test directory for the config files used in subsequent unit tests. +func setup() string { + var err error + // MkDirTemp creates a directory and returns the path to it. The directory + // name is prefixed with the given pattern and suffixed with a random + // string generated at initialization. + testHomeDir, err = os.MkdirTemp(os.TempDir(), "keeper-test-") + if err != nil { + fmt.Println(err) + os.Exit(1) + } + return testHomeDir +} + +// Remove the test directory and all of its contents. +func teardown(d string) { + os.RemoveAll(d) +} + +// Run setup code before and teardown code after all tests have completed. +func TestMain(m *testing.M) { + dir := setup() + code := m.Run() + teardown(dir) + os.Exit(code) +} + +func TestBuildConfigOptions(t *testing.T) { + configOptions := buildConfigOptions(testHomeDir) + if configOptions.ConfigFile != filepath.Join(testHomeDir, ".keeper", "ssh", "config.json") { + t.Errorf("ConfigFile not built correctly") + } + if configOptions.ConfigFileBackup != filepath.Join(testHomeDir, ".keeper", "config.json") { + t.Errorf("ConfigFileBackup not built correctly") + } +} + +func TestGetConfig(t *testing.T) { + testConfigFile, err := os.Create(filepath.Join(testHomeDir, "config.json")) + if err != nil || testConfigFile == nil { + t.Errorf("Unable to create temp config file") + } + + configOptions := ConfigOptions{ + ConfigFile: filepath.Join(testHomeDir, "config.json"), + ConfigFileBackup: filepath.Join(testHomeDir, "config2.json"), + } + + config, err := getConfig(configOptions) + if err != nil { + t.Errorf("Error getting config file: %v", err) + } + if config != configOptions.ConfigFile { + t.Errorf("Error getting config file, expected %v, got %v", configOptions.ConfigFile, config) + } + + // Subsequent unit tests test for the absence of the config file, so + // cleanup of the file needs to happen here instead of in the final + // teardown. + testConfigFile.Close() + err = os.Remove(testConfigFile.Name()) + if err != nil { + t.Errorf("Error removing config file: %v", err) + } + +} + +func TestGetConfigBackup(t *testing.T) { + testBackupConfig, err := os.Create(filepath.Join(testHomeDir, "config2.json")) + if err != nil || testBackupConfig == nil { + t.Errorf("Unable to create temp backup config file") + } + + configOptions := ConfigOptions{ + ConfigFile: filepath.Join(testHomeDir, "config.json"), + ConfigFileBackup: filepath.Join(testHomeDir, "config2.json"), + } + + backupConf, err := getConfig(configOptions) + if err != nil { + t.Errorf("Error getting config file: %v", err) + } + if backupConf != configOptions.ConfigFileBackup { + t.Errorf("Error getting config file, expected %v, got %v", configOptions.ConfigFile, backupConf) + } + + // The next unit test tests for the absence of any config files, so + // cleanup of the file needs to happen here. + testBackupConfig.Close() + err = os.Remove(testBackupConfig.Name()) + if err != nil { + t.Errorf("Error removing config file: %v", err) + } +} + +func TestGetConfigError(t *testing.T) { + configOptions := ConfigOptions{ + ConfigFile: filepath.Join(testHomeDir, "config.json"), + ConfigFileBackup: filepath.Join(testHomeDir, "config2.json"), + } + + _, err := getConfig(configOptions) + if err == nil { + t.Errorf("Expected an error getting config file, got nil") + } +}