diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27ced21..4206824 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,7 @@ jobs: GOPATH: /home/runner/work/git2go run: | git submodule update --init + sudo apt-get install -y --no-install-recommends libssh2-1-dev make build-libgit2-static go get -tags static -t github.com/${{ github.repository }}/... go build -tags static github.com/${{ github.repository }}/... @@ -62,6 +63,7 @@ jobs: - name: Build run: | git submodule update --init + sudo apt-get install -y --no-install-recommends libssh2-1-dev make build-libgit2-static - name: Test run: make TEST_ARGS=-test.v test-static @@ -84,6 +86,7 @@ jobs: - name: Build run: | git submodule update --init + sudo apt-get install -y --no-install-recommends libssh2-1-dev make build-libgit2-dynamic - name: Test run: make TEST_ARGS=-test.v test-dynamic @@ -106,6 +109,7 @@ jobs: - name: Build libgit2 run: | git submodule update --init + sudo apt-get install -y --no-install-recommends libssh2-1-dev sudo ./script/build-libgit2.sh --dynamic --system - name: Test run: make TEST_ARGS=-test.v test @@ -128,6 +132,7 @@ jobs: - name: Build libgit2 run: | git submodule update --init + sudo apt-get install -y --no-install-recommends libssh2-1-dev sudo ./script/build-libgit2.sh --static --system - name: Test run: go test --count=1 --tags "static,system_libgit2" ./... diff --git a/.travis.yml b/.travis.yml index 1717c8e..0b7f482 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,7 @@ go: - tip install: + - sudo apt-get install -y --no-install-recommends libssh2-1-dev - make build-libgit2-static - go get --tags "static" ./... diff --git a/credentials.go b/credentials.go index 4e42b6e..2f683cc 100644 --- a/credentials.go +++ b/credentials.go @@ -2,9 +2,16 @@ package git /* #include + +void _go_git_populate_credential_ssh_custom(git_cred_ssh_custom *cred); */ import "C" -import "unsafe" +import ( + "crypto/rand" + "unsafe" + + "golang.org/x/crypto/ssh" +) type CredType uint @@ -89,3 +96,61 @@ func NewCredDefault() (int, Cred) { ret := C.git_cred_default_new(&cred.ptr) return int(ret), cred } + +type credentialSSHCustomData struct { + signer ssh.Signer +} + +//export credentialSSHCustomFree +func credentialSSHCustomFree(cred *C.git_cred_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) (*Cred, error) { + publicKey := signer.PublicKey().Marshal() + + ccred := (*C.git_cred_ssh_custom)(C.calloc(1, C.size_t(unsafe.Sizeof(C.git_cred_ssh_custom{})))) + ccred.parent.credtype = C.GIT_CREDTYPE_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 := Cred{ + ptr: &ccred.parent, + } + + return &cred, nil +} diff --git a/go.mod b/go.mod index 0228d02..cd69eff 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,9 @@ module github.com/libgit2/git2go/v27 go 1.13 + +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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..35ff116 --- /dev/null +++ b/go.sum @@ -0,0 +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-20201203163018-be400aefbc4c h1:9HhBz5L/UjnK9XLtiZhYAdue5BVKep3PMmS2LuPDt8k= +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/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +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= diff --git a/remote_test.go b/remote_test.go index 4cc3298..4f10c0d 100644 --- a/remote_test.go +++ b/remote_test.go @@ -1,8 +1,21 @@ package git import ( + "bytes" + "crypto/rand" + "crypto/rsa" "fmt" + "io" + "net" + "os" + "os/exec" + "strings" + "sync" "testing" + "time" + + "github.com/google/shlex" + "golang.org/x/crypto/ssh" ) func TestListRemotes(t *testing.T) { @@ -184,3 +197,269 @@ func TestRemotePrune(t *testing.T) { 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 CredType) (ErrorCode, *Cred) { + if allowedTypes&(CredTypeSshKey|CredTypeSshCustom) != 0 { + cred, err := NewCredentialSSHKeyFromSigner(pubKeyUsername, signer) + if err != nil { + t.Logf("failed to create credentials: %v", err) + return ErrorCodeAuth, nil + } + return ErrorCodeOK, cred + } + t.Logf("unknown credential type %+v", allowedTypes) + return ErrorCodeAuth, nil + }, + }, + } + + remote, err := repo.Remotes.Create( + "origin", + fmt.Sprintf("ssh://%s@%s/TestGitRepository", pubKeyUsername, 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") + } +} diff --git a/script/build-libgit2.sh b/script/build-libgit2.sh index 36e11e5..d57c5a5 100755 --- a/script/build-libgit2.sh +++ b/script/build-libgit2.sh @@ -62,11 +62,11 @@ cmake -DTHREADSAFE=ON \ -DCMAKE_BUILD_TYPE="RelWithDebInfo" \ -DCMAKE_INSTALL_PREFIX="${BUILD_INSTALL_PREFIX}" \ -DDEPRECATE_HARD=ON \ - "${VENDORED_PATH}" && + "${VENDORED_PATH}" -if which gmake nproc >/dev/null && [ -f Makefile ]; then - # Make the build parallel if gmake is available and cmake used Makefiles. - exec gmake "-j$(nproc --all)" install +if which make nproc >/dev/null && [ -f Makefile ]; then + # Make the build parallel if make is available and cmake used Makefiles. + exec make "-j$(nproc --all)" install +else + exec cmake --build . --target install fi - -exec cmake --build . --target install diff --git a/testdata/TestGitRepository.git/HEAD b/testdata/TestGitRepository.git/HEAD new file mode 100644 index 0000000..cb089cd --- /dev/null +++ b/testdata/TestGitRepository.git/HEAD @@ -0,0 +1 @@ +ref: refs/heads/master diff --git a/testdata/TestGitRepository.git/config b/testdata/TestGitRepository.git/config new file mode 100644 index 0000000..44b9489 --- /dev/null +++ b/testdata/TestGitRepository.git/config @@ -0,0 +1,6 @@ +[core] + repositoryformatversion = 0 + filemode = true + bare = true +[remote "origin"] + url = https://github.com/libgit2/TestGitRepository diff --git a/testdata/TestGitRepository.git/description b/testdata/TestGitRepository.git/description new file mode 100644 index 0000000..498b267 --- /dev/null +++ b/testdata/TestGitRepository.git/description @@ -0,0 +1 @@ +Unnamed repository; edit this file 'description' to name the repository. diff --git a/testdata/TestGitRepository.git/info/exclude b/testdata/TestGitRepository.git/info/exclude new file mode 100644 index 0000000..a5196d1 --- /dev/null +++ b/testdata/TestGitRepository.git/info/exclude @@ -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] +# *~ diff --git a/testdata/TestGitRepository.git/info/refs b/testdata/TestGitRepository.git/info/refs new file mode 100644 index 0000000..e10a563 --- /dev/null +++ b/testdata/TestGitRepository.git/info/refs @@ -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 diff --git a/testdata/TestGitRepository.git/objects/info/commit-graph b/testdata/TestGitRepository.git/objects/info/commit-graph new file mode 100644 index 0000000..013e2f0 Binary files /dev/null and b/testdata/TestGitRepository.git/objects/info/commit-graph differ diff --git a/testdata/TestGitRepository.git/objects/info/packs b/testdata/TestGitRepository.git/objects/info/packs new file mode 100644 index 0000000..d876b38 --- /dev/null +++ b/testdata/TestGitRepository.git/objects/info/packs @@ -0,0 +1,2 @@ +P pack-ccace4e169a0858c13d9ae781a91d76fc33769b8.pack + diff --git a/testdata/TestGitRepository.git/objects/pack/pack-ccace4e169a0858c13d9ae781a91d76fc33769b8.bitmap b/testdata/TestGitRepository.git/objects/pack/pack-ccace4e169a0858c13d9ae781a91d76fc33769b8.bitmap new file mode 100644 index 0000000..df3442c Binary files /dev/null and b/testdata/TestGitRepository.git/objects/pack/pack-ccace4e169a0858c13d9ae781a91d76fc33769b8.bitmap differ diff --git a/testdata/TestGitRepository.git/objects/pack/pack-ccace4e169a0858c13d9ae781a91d76fc33769b8.idx b/testdata/TestGitRepository.git/objects/pack/pack-ccace4e169a0858c13d9ae781a91d76fc33769b8.idx new file mode 100644 index 0000000..aff5c2e Binary files /dev/null and b/testdata/TestGitRepository.git/objects/pack/pack-ccace4e169a0858c13d9ae781a91d76fc33769b8.idx differ diff --git a/testdata/TestGitRepository.git/objects/pack/pack-ccace4e169a0858c13d9ae781a91d76fc33769b8.pack b/testdata/TestGitRepository.git/objects/pack/pack-ccace4e169a0858c13d9ae781a91d76fc33769b8.pack new file mode 100644 index 0000000..a3a1206 Binary files /dev/null and b/testdata/TestGitRepository.git/objects/pack/pack-ccace4e169a0858c13d9ae781a91d76fc33769b8.pack differ diff --git a/testdata/TestGitRepository.git/packed-refs b/testdata/TestGitRepository.git/packed-refs new file mode 100644 index 0000000..ead149c --- /dev/null +++ b/testdata/TestGitRepository.git/packed-refs @@ -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 diff --git a/testdata/TestGitRepository.git/refs/heads/master b/testdata/TestGitRepository.git/refs/heads/master new file mode 100644 index 0000000..88fc4ea --- /dev/null +++ b/testdata/TestGitRepository.git/refs/heads/master @@ -0,0 +1 @@ +49322bb17d3acc9146f98c97d078513228bbf3c0 diff --git a/wrapper.c b/wrapper.c index 7d79081..8e17cea 100644 --- a/wrapper.c +++ b/wrapper.c @@ -403,6 +403,29 @@ void _go_git_writestream_free(git_writestream *stream) stream->free(stream); } +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_cred_ssh_custom *cred) +{ + cred->parent.free = (void (*)(git_cred *))credentialSSHCustomFree; + cred->sign_callback = credential_ssh_sign_callback; +} + 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);