Add `NewCredentialSSHKeyFromSigner` (#706)

This change adds `NewCredentialSSHKeyFromSigner`, which allows idiomatic
use of SSH keys from Go. This also lets us spin off an SSH server in the
tests.

(cherry picked from commit abf02bc7d7)
This commit is contained in:
lhchavez 2020-12-06 11:55:04 -08:00
parent 07b98b44c3
commit e57ff6c391
20 changed files with 421 additions and 10 deletions

View File

@ -34,6 +34,7 @@ jobs:
GOPATH: /home/runner/work/git2go GOPATH: /home/runner/work/git2go
run: | run: |
git submodule update --init git submodule update --init
sudo apt-get install -y --no-install-recommends libssh2-1-dev
make build-libgit2-static make build-libgit2-static
go get -tags static -t github.com/${{ github.repository }}/... go get -tags static -t github.com/${{ github.repository }}/...
go build -tags static github.com/${{ github.repository }}/... go build -tags static github.com/${{ github.repository }}/...
@ -62,6 +63,7 @@ jobs:
- name: Build - name: Build
run: | run: |
git submodule update --init git submodule update --init
sudo apt-get install -y --no-install-recommends libssh2-1-dev
make build-libgit2-static make build-libgit2-static
- name: Test - name: Test
run: make TEST_ARGS=-test.v test-static run: make TEST_ARGS=-test.v test-static
@ -84,6 +86,7 @@ jobs:
- name: Build - name: Build
run: | run: |
git submodule update --init git submodule update --init
sudo apt-get install -y --no-install-recommends libssh2-1-dev
make build-libgit2-dynamic make build-libgit2-dynamic
- name: Test - name: Test
run: make TEST_ARGS=-test.v test-dynamic run: make TEST_ARGS=-test.v test-dynamic
@ -108,6 +111,7 @@ jobs:
- name: Build libgit2 ${{ matrix.libgit2 }} - name: Build libgit2 ${{ matrix.libgit2 }}
run: | run: |
git submodule update --init git submodule update --init
sudo apt-get install -y --no-install-recommends libssh2-1-dev
sudo env BUILD_LIBGIT_REF=v${{ matrix.libgit2 }} ./script/build-libgit2.sh --dynamic --system sudo env BUILD_LIBGIT_REF=v${{ matrix.libgit2 }} ./script/build-libgit2.sh --dynamic --system
- name: Test - name: Test
run: make TEST_ARGS=-test.v test run: make TEST_ARGS=-test.v test
@ -130,6 +134,7 @@ jobs:
- name: Build libgit2 - name: Build libgit2
run: | run: |
git submodule update --init git submodule update --init
sudo apt-get install -y --no-install-recommends libssh2-1-dev
sudo ./script/build-libgit2.sh --static --system sudo ./script/build-libgit2.sh --static --system
- name: Test - name: Test
run: go test --count=1 --tags "static,system_libgit2" ./... run: go test --count=1 --tags "static,system_libgit2" ./...

View File

@ -8,6 +8,7 @@ go:
- tip - tip
install: install:
- sudo apt-get install -y --no-install-recommends libssh2-1-dev
- make build-libgit2-static - make build-libgit2-static
- go get --tags "static" ./... - go get --tags "static" ./...

View File

@ -3,15 +3,20 @@ package git
/* /*
#include <git2.h> #include <git2.h>
#include <git2/credential.h> #include <git2/credential.h>
#include <git2/sys/credential.h>
git_credential_t _go_git_credential_credtype(git_credential *cred); git_credential_t _go_git_credential_credtype(git_credential *cred);
void _go_git_populate_credential_ssh_custom(git_credential_ssh_custom *cred);
*/ */
import "C" import "C"
import ( import (
"crypto/rand"
"fmt" "fmt"
"runtime" "runtime"
"strings" "strings"
"unsafe" "unsafe"
"golang.org/x/crypto/ssh"
) )
// CredentialType is a bitmask of supported credential types. // CredentialType is a bitmask of supported credential types.
@ -192,6 +197,63 @@ func NewCredentialSSHKeyFromAgent(username string) (*Credential, error) {
return cred, nil return cred, nil
} }
type credentialSSHCustomData struct {
signer ssh.Signer
}
//export credentialSSHCustomFree
func credentialSSHCustomFree(cred *C.git_credential_ssh_custom) {
if cred == nil {
return
}
C.free(unsafe.Pointer(cred.username))
C.free(unsafe.Pointer(cred.publickey))
pointerHandles.Untrack(cred.payload)
C.free(unsafe.Pointer(cred))
}
//export credentialSSHSignCallback
func credentialSSHSignCallback(
errorMessage **C.char,
sig **C.uchar,
sig_len *C.size_t,
data *C.uchar,
data_len C.size_t,
handle unsafe.Pointer,
) C.int {
signer := pointerHandles.Get(handle).(*credentialSSHCustomData).signer
signature, err := signer.Sign(rand.Reader, C.GoBytes(unsafe.Pointer(data), C.int(data_len)))
if err != nil {
return setCallbackError(errorMessage, err)
}
*sig = (*C.uchar)(C.CBytes(signature.Blob))
*sig_len = C.size_t(len(signature.Blob))
return C.int(ErrorCodeOK)
}
// NewCredentialSSHKeyFromSigner creates new SSH credentials using the provided signer.
func NewCredentialSSHKeyFromSigner(username string, signer ssh.Signer) (*Credential, error) {
publicKey := signer.PublicKey().Marshal()
ccred := (*C.git_credential_ssh_custom)(C.calloc(1, C.size_t(unsafe.Sizeof(C.git_credential_ssh_custom{}))))
ccred.parent.credtype = C.GIT_CREDENTIAL_SSH_CUSTOM
ccred.username = C.CString(username)
ccred.publickey = (*C.char)(C.CBytes(publicKey))
ccred.publickey_len = C.size_t(len(publicKey))
C._go_git_populate_credential_ssh_custom(ccred)
data := credentialSSHCustomData{
signer: signer,
}
ccred.payload = pointerHandles.Track(&data)
cred := newCredential()
cred.ptr = &ccred.parent
return cred, nil
}
func NewCredentialDefault() (*Credential, error) { func NewCredentialDefault() (*Credential, error) {
runtime.LockOSThread() runtime.LockOSThread()
defer runtime.UnlockOSThread() defer runtime.UnlockOSThread()

6
go.mod
View File

@ -2,4 +2,8 @@ module github.com/libgit2/git2go/v30
go 1.13 go 1.13
require golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de require (
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c
golang.org/x/sys v0.0.0-20201204225414-ed752295db88 // indirect
)

12
go.sum
View File

@ -1,7 +1,13 @@
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de h1:ikNHVSjEfnvz6sxdSPCaPt572qowuyMDMJLLm3Db3ig= golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c h1:9HhBz5L/UjnK9XLtiZhYAdue5BVKep3PMmS2LuPDt8k=
golang.org/x/crypto v0.0.0-20200728195943-123391ffb6de/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20201203163018-be400aefbc4c/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88 h1:KmZPnMocC93w341XZp26yTJg8Za7lhb2KhkYmixoeso=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221 h1:/ZHdbVpdR/jk3g30/d4yUL0JU9kksj8+F/bnQUVLGDM=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=

View File

@ -1,8 +1,21 @@
package git package git
import ( import (
"bytes"
"crypto/rand"
"crypto/rsa"
"fmt" "fmt"
"io"
"net"
"os"
"os/exec"
"strings"
"sync"
"testing" "testing"
"time"
"github.com/google/shlex"
"golang.org/x/crypto/ssh"
) )
func TestListRemotes(t *testing.T) { func TestListRemotes(t *testing.T) {
@ -184,3 +197,266 @@ func TestRemotePrune(t *testing.T) {
t.Fatal("Expected error getting a pruned reference") t.Fatal("Expected error getting a pruned reference")
} }
} }
func newChannelPipe(t *testing.T, w io.Writer, wg *sync.WaitGroup) (*os.File, error) {
pr, pw, err := os.Pipe()
if err != nil {
return nil, err
}
wg.Add(1)
go func() {
_, err := io.Copy(w, pr)
if err != nil && err != io.EOF {
t.Logf("Failed to copy: %v", err)
}
wg.Done()
}()
return pw, nil
}
func startSSHServer(t *testing.T, hostKey ssh.Signer, authorizedKeys []ssh.PublicKey) net.Listener {
t.Helper()
marshaledAuthorizedKeys := make([][]byte, len(authorizedKeys))
for i, authorizedKey := range authorizedKeys {
marshaledAuthorizedKeys[i] = authorizedKey.Marshal()
}
config := &ssh.ServerConfig{
PublicKeyCallback: func(c ssh.ConnMetadata, pubKey ssh.PublicKey) (*ssh.Permissions, error) {
marshaledPubKey := pubKey.Marshal()
for _, marshaledAuthorizedKey := range marshaledAuthorizedKeys {
if bytes.Equal(marshaledPubKey, marshaledAuthorizedKey) {
return &ssh.Permissions{
// Record the public key used for authentication.
Extensions: map[string]string{
"pubkey-fp": ssh.FingerprintSHA256(pubKey),
},
}, nil
}
}
t.Logf("unknown public key for %q:\n\t%+v\n\t%+v\n", c.User(), pubKey.Marshal(), authorizedKeys)
return nil, fmt.Errorf("unknown public key for %q", c.User())
},
}
config.AddHostKey(hostKey)
listener, err := net.Listen("tcp", "localhost:0")
if err != nil {
t.Fatalf("Failed to listen for connection: %v", err)
}
go func() {
nConn, err := listener.Accept()
if err != nil {
if strings.Contains(err.Error(), "use of closed network connection") {
return
}
t.Logf("Failed to accept incoming connection: %v", err)
return
}
defer nConn.Close()
conn, chans, reqs, err := ssh.NewServerConn(nConn, config)
if err != nil {
t.Logf("failed to handshake: %+v, %+v", conn, err)
return
}
// The incoming Request channel must be serviced.
go func() {
for newRequest := range reqs {
t.Logf("new request %v", newRequest)
}
}()
// Service only the first channel request
newChannel := <-chans
defer func() {
for newChannel := range chans {
t.Logf("new channel %v", newChannel)
newChannel.Reject(ssh.UnknownChannelType, "server closing")
}
}()
// Channels have a type, depending on the application level
// protocol intended. In the case of a shell, the type is
// "session" and ServerShell may be used to present a simple
// terminal interface.
if newChannel.ChannelType() != "session" {
newChannel.Reject(ssh.UnknownChannelType, "unknown channel type")
return
}
channel, requests, err := newChannel.Accept()
if err != nil {
t.Logf("Could not accept channel: %v", err)
return
}
defer channel.Close()
// Sessions have out-of-band requests such as "shell",
// "pty-req" and "env". Here we handle only the
// "exec" request.
req := <-requests
if req.Type != "exec" {
req.Reply(false, nil)
return
}
// RFC 4254 Section 6.5.
var payload struct {
Command string
}
if err := ssh.Unmarshal(req.Payload, &payload); err != nil {
t.Logf("invalid payload on channel %v: %v", channel, err)
req.Reply(false, nil)
return
}
args, err := shlex.Split(payload.Command)
if err != nil {
t.Logf("invalid command on channel %v: %v", channel, err)
req.Reply(false, nil)
return
}
if len(args) < 2 || (args[0] != "git-upload-pack" && args[0] != "git-receive-pack") {
t.Logf("invalid command (%v) on channel %v: %v", args, channel, err)
req.Reply(false, nil)
return
}
req.Reply(true, nil)
go func(in <-chan *ssh.Request) {
for req := range in {
t.Logf("draining request %v", req)
}
}(requests)
// The first parameter is the (absolute) path of the repository.
args[1] = "./testdata" + args[1]
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdin = channel
var wg sync.WaitGroup
stdoutPipe, err := newChannelPipe(t, channel, &wg)
if err != nil {
t.Logf("Failed to create stdout pipe: %v", err)
return
}
cmd.Stdout = stdoutPipe
stderrPipe, err := newChannelPipe(t, channel.Stderr(), &wg)
if err != nil {
t.Logf("Failed to create stderr pipe: %v", err)
return
}
cmd.Stderr = stderrPipe
go func() {
wg.Wait()
channel.CloseWrite()
}()
err = cmd.Start()
if err != nil {
t.Logf("Failed to start %v: %v", args, err)
return
}
// Once the process has started, we need to close the write end of the
// pipes from this process so that we can know when the child has done
// writing to it.
stdoutPipe.Close()
stderrPipe.Close()
timer := time.AfterFunc(5*time.Second, func() {
t.Log("process timed out, terminating")
cmd.Process.Kill()
})
defer timer.Stop()
err = cmd.Wait()
if err != nil {
t.Logf("Failed to run %v: %v", args, err)
return
}
}()
return listener
}
func TestRemoteSSH(t *testing.T) {
t.Parallel()
pubKeyUsername := "testuser"
hostPrivKey, err := rsa.GenerateKey(rand.Reader, 512)
if err != nil {
t.Fatalf("Failed to generate the host RSA private key: %v", err)
}
hostSigner, err := ssh.NewSignerFromKey(hostPrivKey)
if err != nil {
t.Fatalf("Failed to generate SSH hostSigner: %v", err)
}
privKey, err := rsa.GenerateKey(rand.Reader, 512)
if err != nil {
t.Fatalf("Failed to generate the user RSA private key: %v", err)
}
signer, err := ssh.NewSignerFromKey(privKey)
if err != nil {
t.Fatalf("Failed to generate SSH signer: %v", err)
}
// This is in the format "xx:xx:xx:...", so we remove the colons so that it
// matches the fmt.Sprintf() below.
// Note that not all libssh2 implementations support the SHA256 fingerprint,
// so we use MD5 here for testing.
publicKeyFingerprint := strings.Replace(ssh.FingerprintLegacyMD5(hostSigner.PublicKey()), ":", "", -1)
listener := startSSHServer(t, hostSigner, []ssh.PublicKey{signer.PublicKey()})
defer listener.Close()
repo := createTestRepo(t)
defer cleanupTestRepo(t, repo)
certificateCheckCallbackCalled := false
fetchOpts := FetchOptions{
RemoteCallbacks: RemoteCallbacks{
CertificateCheckCallback: func(cert *Certificate, valid bool, hostname string) ErrorCode {
hostkeyFingerprint := fmt.Sprintf("%x", cert.Hostkey.HashMD5[:])
if hostkeyFingerprint != publicKeyFingerprint {
t.Logf("server hostkey %q, want %q", hostkeyFingerprint, publicKeyFingerprint)
return ErrorCodeAuth
}
certificateCheckCallbackCalled = true
return ErrorCodeOK
},
CredentialsCallback: func(url, username string, allowedTypes CredentialType) (*Credential, error) {
if allowedTypes&(CredentialTypeSSHKey|CredentialTypeSSHCustom|CredentialTypeSSHMemory) != 0 {
return NewCredentialSSHKeyFromSigner(pubKeyUsername, signer)
}
if (allowedTypes & CredentialTypeUsername) != 0 {
return NewCredentialUsername(pubKeyUsername)
}
return nil, fmt.Errorf("unknown credential type %+v", allowedTypes)
},
},
}
remote, err := repo.Remotes.Create(
"origin",
fmt.Sprintf("ssh://%s/TestGitRepository", listener.Addr().String()),
)
checkFatal(t, err)
defer remote.Free()
err = remote.Fetch(nil, &fetchOpts, "")
checkFatal(t, err)
if !certificateCheckCallbackCalled {
t.Fatalf("CertificateCheckCallback was not called")
}
heads, err := remote.Ls()
checkFatal(t, err)
if len(heads) == 0 {
t.Error("Expected remote heads")
}
}

View File

@ -68,11 +68,11 @@ cmake -DTHREADSAFE=ON \
-DCMAKE_BUILD_TYPE="RelWithDebInfo" \ -DCMAKE_BUILD_TYPE="RelWithDebInfo" \
-DCMAKE_INSTALL_PREFIX="${BUILD_INSTALL_PREFIX}" \ -DCMAKE_INSTALL_PREFIX="${BUILD_INSTALL_PREFIX}" \
-DCMAKE_INSTALL_LIBDIR="lib" \ -DCMAKE_INSTALL_LIBDIR="lib" \
"${VENDORED_PATH}" && "${VENDORED_PATH}"
if which gmake nproc >/dev/null && [ -f Makefile ]; then if which make nproc >/dev/null && [ -f Makefile ]; then
# Make the build parallel if gmake is available and cmake used Makefiles. # Make the build parallel if make is available and cmake used Makefiles.
exec gmake "-j$(nproc --all)" install exec make "-j$(nproc --all)" install
else
exec cmake --build . --target install
fi fi
exec cmake --build . --target install

1
testdata/TestGitRepository.git/HEAD vendored Normal file
View File

@ -0,0 +1 @@
ref: refs/heads/master

6
testdata/TestGitRepository.git/config vendored Normal file
View File

@ -0,0 +1,6 @@
[core]
repositoryformatversion = 0
filemode = true
bare = true
[remote "origin"]
url = https://github.com/libgit2/TestGitRepository

View File

@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View File

@ -0,0 +1,6 @@
# git ls-files --others --exclude-from=.git/info/exclude
# Lines that start with '#' are comments.
# For a project mostly in C, the following would be a good set of
# exclude patterns (uncomment them if you want to use them):
# *.[oa]
# *~

View File

@ -0,0 +1,8 @@
0966a434eb1a025db6b71485ab63a3bfbea520b6 refs/heads/first-merge
49322bb17d3acc9146f98c97d078513228bbf3c0 refs/heads/master
42e4e7c5e507e113ebbb7801b16b52cf867b7ce1 refs/heads/no-parent
d96c4e80345534eccee5ac7b07fc7603b56124cb refs/tags/annotated_tag
c070ad8c08840c8116da865b2d65593a6bb9cd2a refs/tags/annotated_tag^{}
55a1a760df4b86a02094a904dfa511deb5655905 refs/tags/blob
8f50ba15d49353813cc6e20298002c0d17b0a9ee refs/tags/commit_tree
6e0c7bdb9b4ed93212491ee778ca1c65047cab4e refs/tags/nearly-dangling

Binary file not shown.

View File

@ -0,0 +1,2 @@
P pack-ccace4e169a0858c13d9ae781a91d76fc33769b8.pack

View File

@ -0,0 +1,9 @@
# pack-refs with: peeled fully-peeled sorted
0966a434eb1a025db6b71485ab63a3bfbea520b6 refs/heads/first-merge
49322bb17d3acc9146f98c97d078513228bbf3c0 refs/heads/master
42e4e7c5e507e113ebbb7801b16b52cf867b7ce1 refs/heads/no-parent
d96c4e80345534eccee5ac7b07fc7603b56124cb refs/tags/annotated_tag
^c070ad8c08840c8116da865b2d65593a6bb9cd2a
55a1a760df4b86a02094a904dfa511deb5655905 refs/tags/blob
8f50ba15d49353813cc6e20298002c0d17b0a9ee refs/tags/commit_tree
6e0c7bdb9b4ed93212491ee778ca1c65047cab4e refs/tags/nearly-dangling

View File

@ -0,0 +1 @@
49322bb17d3acc9146f98c97d078513228bbf3c0

View File

@ -437,6 +437,29 @@ git_credential_t _go_git_credential_credtype(git_credential *cred)
return cred->credtype; return cred->credtype;
} }
static int credential_ssh_sign_callback(
LIBSSH2_SESSION *session,
unsigned char **sig, size_t *sig_len,
const unsigned char *data, size_t data_len,
void **abstract)
{
char *error_message = NULL;
const int ret = credentialSSHSignCallback(
&error_message,
sig,
sig_len,
(unsigned char *)data,
data_len,
(void *)*(uintptr_t *)abstract);
return set_callback_error(error_message, ret);
}
void _go_git_populate_credential_ssh_custom(git_credential_ssh_custom *cred)
{
cred->parent.free = (void (*)(git_credential *))credentialSSHCustomFree;
cred->sign_callback = credential_ssh_sign_callback;
}
int _go_git_odb_write_pack(git_odb_writepack **out, git_odb *db, void *progress_payload) int _go_git_odb_write_pack(git_odb_writepack **out, git_odb *db, void *progress_payload)
{ {
return git_odb_write_pack(out, db, transfer_progress_callback, progress_payload); return git_odb_write_pack(out, db, transfer_progress_callback, progress_payload);