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
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.vscode
*.exe
cover.out
coverage.html
76 changes: 74 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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 <path to this binary>
git config user.signingkey <SSH Key UID>
```

Your git config will now include these attributes:

```ini
[commit]
gpgsign = true
[gpg]
format = ssh
[user]
signingKey = <SSH Key UID
[gpg "ssh"]
program = path\to\sshsign.exe
```

## Usage

Git is now configured to automatically sign all commits, regardless of whether you use the terminal or an IDE interface to interact with git. It also removes the need to use the `-S` flag for commit signing.

You can confirm your commit has been signed with `git show --pretty=raw`.

## Contirbuting

This module uses the built-in golang tooling for building and testing. For example:

```shell
# Run unit tests
go test ./...

# Build a local binary
go build -o ssh-sign.exe ./cmd/ssh-sign/main.go
```

You can submit issues and enhancement requests [here](https://github.com/Keeper-Security/git-ssh-sign/issues).
68 changes: 68 additions & 0 deletions cmd/ssh-sign/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package main

import (
"fmt"
"os"

"github.com/Keeper-Security/git-ssh-sign/internal/sign"
"github.com/Keeper-Security/git-ssh-sign/internal/vault"
)

func main() {
/*
When the gpg.format = ssh, git calls this program and will pass the
following arguments:

-Y sign -n git -f <KEY> /tmp/.git_signing_buffer_file

The <KEY> 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)
}
10 changes: 10 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
7 changes: 7 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
110 changes: 110 additions & 0 deletions internal/sign/sign.go
Original file line number Diff line number Diff line change
@@ -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
}
97 changes: 97 additions & 0 deletions internal/sign/sign_test.go
Original file line number Diff line number Diff line change
@@ -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
}
})
}
}
Loading